@cfio/cohort-sync 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -16,6 +16,14 @@ __export(keychain_exports, {
16
16
  setCredential: () => setCredential
17
17
  });
18
18
  import { execFile } from "node:child_process";
19
+ import os3 from "node:os";
20
+ function assertMacOS(operation) {
21
+ if (os3.platform() !== "darwin") {
22
+ throw new Error(
23
+ `cohort-sync: ${operation} requires macOS Keychain. On Linux/Windows, set your API key in OpenClaw config: plugins.entries.cohort-sync.config.apiKey`
24
+ );
25
+ }
26
+ }
19
27
  function securityCmd(args) {
20
28
  return new Promise((resolve, reject) => {
21
29
  execFile("security", args, { timeout: 5e3 }, (err, stdout, stderr) => {
@@ -37,6 +45,7 @@ function isNotFoundError(err) {
37
45
  return false;
38
46
  }
39
47
  async function setCredential(apiUrl, apiKey) {
48
+ assertMacOS("storing credentials");
40
49
  await securityCmd([
41
50
  "add-generic-password",
42
51
  "-s",
@@ -49,6 +58,7 @@ async function setCredential(apiUrl, apiKey) {
49
58
  ]);
50
59
  }
51
60
  async function getCredential(apiUrl) {
61
+ assertMacOS("reading credentials");
52
62
  try {
53
63
  const { stdout } = await securityCmd([
54
64
  "find-generic-password",
@@ -65,6 +75,7 @@ async function getCredential(apiUrl) {
65
75
  }
66
76
  }
67
77
  async function deleteCredential(apiUrl) {
78
+ assertMacOS("deleting credentials");
68
79
  try {
69
80
  await securityCmd([
70
81
  "delete-generic-password",
@@ -2699,6 +2710,225 @@ var Type = type_exports2;
2699
2710
 
2700
2711
  // src/sync.ts
2701
2712
  import { execSync } from "node:child_process";
2713
+ function extractJson(raw) {
2714
+ const jsonStart = raw.search(/[\[{]/);
2715
+ const jsonEndBracket = raw.lastIndexOf("]");
2716
+ const jsonEndBrace = raw.lastIndexOf("}");
2717
+ const jsonEnd = Math.max(jsonEndBracket, jsonEndBrace);
2718
+ if (jsonStart === -1 || jsonEnd === -1 || jsonEnd < jsonStart) {
2719
+ throw new Error("No JSON found in output");
2720
+ }
2721
+ return raw.slice(jsonStart, jsonEnd + 1);
2722
+ }
2723
+ function fetchSkills(logger) {
2724
+ try {
2725
+ const raw = execSync("openclaw skills list --json", {
2726
+ encoding: "utf8",
2727
+ timeout: 3e4,
2728
+ stdio: ["ignore", "pipe", "ignore"],
2729
+ env: { ...process.env, NO_COLOR: "1" }
2730
+ });
2731
+ const parsed = JSON.parse(extractJson(raw));
2732
+ const list = Array.isArray(parsed) ? parsed : parsed?.skills ?? [];
2733
+ return list.map((s) => ({
2734
+ name: String(s.name ?? s.id ?? "unknown"),
2735
+ description: String(s.description ?? ""),
2736
+ source: String(s.source ?? s.origin ?? "unknown"),
2737
+ ...s.emoji ? { emoji: String(s.emoji) } : {}
2738
+ }));
2739
+ } catch (err) {
2740
+ logger.warn(`cohort-sync: failed to fetch skills: ${String(err)}`);
2741
+ return [];
2742
+ }
2743
+ }
2744
+ var VALID_STATUSES = /* @__PURE__ */ new Set(["idle", "working", "waiting"]);
2745
+ function normalizeStatus(status) {
2746
+ return VALID_STATUSES.has(status) ? status : "idle";
2747
+ }
2748
+ async function v1Get(apiUrl, apiKey, path4) {
2749
+ const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
2750
+ headers: { Authorization: `Bearer ${apiKey}` },
2751
+ signal: AbortSignal.timeout(1e4)
2752
+ });
2753
+ if (!res.ok) throw new Error(`GET ${path4} \u2192 ${res.status}`);
2754
+ return res.json();
2755
+ }
2756
+ async function v1Patch(apiUrl, apiKey, path4, body) {
2757
+ const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
2758
+ method: "PATCH",
2759
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
2760
+ body: JSON.stringify(body),
2761
+ signal: AbortSignal.timeout(1e4)
2762
+ });
2763
+ if (!res.ok) throw new Error(`PATCH ${path4} \u2192 ${res.status}`);
2764
+ }
2765
+ async function v1Post(apiUrl, apiKey, path4, body) {
2766
+ const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
2767
+ method: "POST",
2768
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
2769
+ body: JSON.stringify(body),
2770
+ signal: AbortSignal.timeout(1e4)
2771
+ });
2772
+ if (!res.ok) throw new Error(`POST ${path4} \u2192 ${res.status}`);
2773
+ }
2774
+ async function checkForUpdate(currentVersion, logger) {
2775
+ try {
2776
+ const res = await fetch("https://registry.npmjs.org/@cfio/cohort-sync/latest", {
2777
+ signal: AbortSignal.timeout(5e3)
2778
+ });
2779
+ if (!res.ok) return;
2780
+ const data = await res.json();
2781
+ const latest = data.version;
2782
+ if (latest && latest !== currentVersion) {
2783
+ logger.warn(
2784
+ `cohort-sync: update available (${currentVersion} \u2192 ${latest}) \u2014 run "npm install -g @cfio/cohort-sync" to update`
2785
+ );
2786
+ }
2787
+ } catch {
2788
+ }
2789
+ }
2790
+ async function syncAgentStatus(agentName, status, model, cfg, logger) {
2791
+ try {
2792
+ const data = await v1Get(cfg.apiUrl, cfg.apiKey, "/api/v1/agents?limit=100");
2793
+ const agents = data?.data ?? [];
2794
+ const agent = agents.find(
2795
+ (a) => a.name.toLowerCase() === agentName.toLowerCase()
2796
+ );
2797
+ if (!agent) {
2798
+ const available = agents.map((a) => a.name).join(", ") || "(none)";
2799
+ logger.warn(
2800
+ `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.`
2801
+ );
2802
+ return;
2803
+ }
2804
+ await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${agent.id}`, {
2805
+ status: normalizeStatus(status),
2806
+ model
2807
+ });
2808
+ logger.info(`cohort-sync: agent "${agentName}" \u2192 ${normalizeStatus(status)}`);
2809
+ } catch (err) {
2810
+ logger.warn(`cohort-sync: syncAgentStatus failed: ${String(err)}`);
2811
+ }
2812
+ }
2813
+ async function syncSkillsToV1(skills, cfg, logger) {
2814
+ for (const skill of skills) {
2815
+ try {
2816
+ await v1Post(cfg.apiUrl, cfg.apiKey, "/api/v1/skills", {
2817
+ name: skill.name,
2818
+ description: skill.description
2819
+ });
2820
+ } catch (err) {
2821
+ logger.warn(`cohort-sync: failed to sync skill "${skill.name}": ${String(err)}`);
2822
+ }
2823
+ }
2824
+ }
2825
+ var lastKnownRoster = [];
2826
+ function getLastKnownRoster() {
2827
+ return lastKnownRoster;
2828
+ }
2829
+ async function reconcileRoster(openClawAgents, cfg, logger) {
2830
+ const data = await v1Get(cfg.apiUrl, cfg.apiKey, "/api/v1/agents?limit=100");
2831
+ const cohortAgents = data?.data ?? [];
2832
+ const cohortByName = new Map(cohortAgents.map((a) => [a.name.toLowerCase(), a]));
2833
+ const openClawNames = new Set(
2834
+ openClawAgents.map((a) => {
2835
+ const nameMap = cfg.agentNameMap;
2836
+ return (nameMap?.[a.id] ?? a.identity?.name ?? a.id).toLowerCase();
2837
+ })
2838
+ );
2839
+ for (const oc of openClawAgents) {
2840
+ const agentName = (cfg.agentNameMap?.[oc.id] ?? oc.identity?.name ?? oc.id).toLowerCase();
2841
+ const existing = cohortByName.get(agentName);
2842
+ if (!existing) {
2843
+ try {
2844
+ await v1Post(cfg.apiUrl, cfg.apiKey, "/api/v1/agents", {
2845
+ name: agentName,
2846
+ displayName: oc.identity?.name ?? agentName,
2847
+ emoji: oc.identity?.emoji ?? "\u{1F916}",
2848
+ model: oc.model,
2849
+ status: "idle"
2850
+ });
2851
+ logger.info(`cohort-sync: provisioned new agent "${agentName}"`);
2852
+ } catch (err) {
2853
+ logger.warn(`cohort-sync: failed to provision agent "${agentName}": ${String(err)}`);
2854
+ }
2855
+ } else {
2856
+ const updates = {
2857
+ model: oc.model,
2858
+ status: "idle"
2859
+ };
2860
+ if (oc.identity?.name) {
2861
+ updates.displayName = oc.identity.name;
2862
+ }
2863
+ if (oc.identity?.emoji) {
2864
+ updates.emoji = oc.identity.emoji;
2865
+ }
2866
+ try {
2867
+ await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${existing.id}`, updates);
2868
+ } catch (err) {
2869
+ logger.warn(`cohort-sync: failed to update agent "${agentName}": ${String(err)}`);
2870
+ }
2871
+ }
2872
+ }
2873
+ for (const cohort of cohortAgents) {
2874
+ if (!openClawNames.has(cohort.name.toLowerCase())) {
2875
+ if (cohort.status === "archived" || cohort.status === "deleted" || cohort.status === "unreachable") {
2876
+ continue;
2877
+ }
2878
+ try {
2879
+ await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${cohort.id}`, {
2880
+ status: "unreachable"
2881
+ });
2882
+ logger.info(`cohort-sync: marked agent "${cohort.name}" as unreachable`);
2883
+ } catch (err) {
2884
+ logger.warn(
2885
+ `cohort-sync: failed to mark agent "${cohort.name}" as unreachable: ${String(err)}`
2886
+ );
2887
+ }
2888
+ }
2889
+ }
2890
+ const updatedData = await v1Get(cfg.apiUrl, cfg.apiKey, "/api/v1/agents?limit=100");
2891
+ const finalRoster = updatedData?.data ?? cohortAgents;
2892
+ lastKnownRoster = finalRoster;
2893
+ return finalRoster;
2894
+ }
2895
+ async function markAllUnreachable(cfg, logger) {
2896
+ const roster = getLastKnownRoster();
2897
+ if (roster.length === 0) {
2898
+ logger.warn("cohort-sync: no cached roster \u2014 skipping markAllUnreachable");
2899
+ return;
2900
+ }
2901
+ for (const agent of roster) {
2902
+ if (agent.status === "unreachable" || agent.status === "archived" || agent.status === "deleted") {
2903
+ continue;
2904
+ }
2905
+ try {
2906
+ await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${agent.id}`, {
2907
+ status: "unreachable"
2908
+ });
2909
+ } catch (err) {
2910
+ logger.warn(`cohort-sync: failed to mark "${agent.name}" as unreachable: ${String(err)}`);
2911
+ }
2912
+ }
2913
+ logger.info("cohort-sync: all agents marked unreachable");
2914
+ }
2915
+ async function fullSync(agentName, model, cfg, logger, openClawAgents) {
2916
+ logger.info("cohort-sync: full sync starting");
2917
+ if (openClawAgents && openClawAgents.length > 0) {
2918
+ try {
2919
+ await reconcileRoster(openClawAgents, cfg, logger);
2920
+ } catch (err) {
2921
+ logger.warn(`cohort-sync: roster reconciliation failed: ${String(err)}`);
2922
+ }
2923
+ } else {
2924
+ await syncAgentStatus(agentName, "working", model, cfg, logger);
2925
+ }
2926
+ const skills = fetchSkills(logger);
2927
+ if (skills.length > 0) {
2928
+ await syncSkillsToV1(skills, cfg, logger);
2929
+ }
2930
+ logger.info("cohort-sync: full sync complete");
2931
+ }
2702
2932
 
2703
2933
  // ../../node_modules/.pnpm/convex@1.33.0_patch_hash=l43bztwr6e2lbmpd6ao6hmcg24_react@19.2.1/node_modules/convex/dist/esm/index.js
2704
2934
  var version = "1.33.0";
@@ -7630,14 +7860,14 @@ var require_node_gyp_build = __commonJS({
7630
7860
  "../common/temp/node_modules/.pnpm/node-gyp-build@4.8.4/node_modules/node-gyp-build/node-gyp-build.js"(exports, module) {
7631
7861
  var fs4 = __require("fs");
7632
7862
  var path4 = __require("path");
7633
- var os3 = __require("os");
7863
+ var os4 = __require("os");
7634
7864
  var runtimeRequire = typeof __webpack_require__ === "function" ? __non_webpack_require__ : __require;
7635
7865
  var vars = process.config && process.config.variables || {};
7636
7866
  var prebuildsOnly = !!process.env.PREBUILDS_ONLY;
7637
7867
  var abi = process.versions.modules;
7638
7868
  var runtime = isElectron() ? "electron" : isNwjs() ? "node-webkit" : "node";
7639
- var arch = process.env.npm_config_arch || os3.arch();
7640
- var platform = process.env.npm_config_platform || os3.platform();
7869
+ var arch = process.env.npm_config_arch || os4.arch();
7870
+ var platform = process.env.npm_config_platform || os4.platform();
7641
7871
  var libc = process.env.LIBC || (isAlpine(platform) ? "musl" : "glibc");
7642
7872
  var armv = process.env.ARM_VERSION || (arch === "arm64" ? "8" : vars.arm_version) || "";
7643
7873
  var uv = (process.versions.uv || "").split(".")[0];
@@ -11542,127 +11772,125 @@ function reverseResolveAgentName(cohortName, forwardMap) {
11542
11772
  return cohortName;
11543
11773
  }
11544
11774
 
11545
- // src/subscription.ts
11546
- function deriveConvexUrl(apiUrl) {
11547
- return apiUrl.replace(/\.convex\.site\/?$/, ".convex.cloud");
11548
- }
11549
- var DEFAULT_BEHAVIORAL_PROMPT = `BEFORE RESPONDING:
11550
- - Does your planned response address the task's stated scope? If not, do not comment.
11551
- - Do not post acknowledgment-only responses ("got it", "sounds good", "confirmed"). If you have no new information to add, do not comment.
11552
- - If the work is complete, transition the task to "waiting" and set noReply=true on your final comment, then stop working on this task.`;
11553
- function buildNotificationMessage(n) {
11554
- let header;
11555
- let cta;
11556
- switch (n.type) {
11557
- case "comment":
11558
- if (n.isMentioned) {
11559
- header = `You were @mentioned on task #${n.taskNumber} "${n.taskTitle}"
11560
- By: ${n.actorName}`;
11561
- cta = "You were directly mentioned. Read the comment and respond using the cohort_comment tool.";
11562
- } else {
11563
- header = `New comment on task #${n.taskNumber} "${n.taskTitle}"
11564
- From: ${n.actorName}`;
11565
- cta = "Read the comment thread and respond using the cohort_comment tool if appropriate.";
11566
- }
11567
- break;
11568
- case "assignment":
11569
- header = `You were assigned to task #${n.taskNumber} "${n.taskTitle}"
11570
- By: ${n.actorName}`;
11571
- cta = "Review the task description and begin working on it.";
11572
- break;
11573
- case "status_change":
11574
- header = `Status changed on task #${n.taskNumber} "${n.taskTitle}"
11575
- By: ${n.actorName}`;
11576
- cta = "Review the status change and take any follow-up action needed.";
11577
- break;
11578
- default:
11579
- header = `Notification on task #${n.taskNumber} "${n.taskTitle}"
11580
- From: ${n.actorName}`;
11581
- cta = "Check the task and respond if needed.";
11582
- }
11583
- const body = n.preview ? `
11584
- Comment: "${n.preview}"` : "";
11585
- let scope = "";
11586
- if (n.taskDescription && n.type === "comment") {
11587
- const truncated = n.taskDescription.length > 500 ? n.taskDescription.slice(0, 500) + "..." : n.taskDescription;
11588
- scope = `
11589
-
11590
- Scope: ${truncated}`;
11591
- }
11592
- const prompt = n.behavioralPrompt ? `${n.behavioralPrompt}
11593
-
11594
- ${DEFAULT_BEHAVIORAL_PROMPT}` : DEFAULT_BEHAVIORAL_PROMPT;
11595
- const promptBlock = n.type === "comment" ? `
11596
-
11597
- ---
11598
- ${prompt}` : "";
11599
- return `${header}${scope}${body}
11600
-
11601
- ${cta}${promptBlock}`;
11602
- }
11603
- async function injectNotification(port, hooksToken, n, agentId = "main") {
11604
- const response = await fetch(`http://localhost:${port}/hooks/agent`, {
11605
- method: "POST",
11606
- headers: {
11607
- "Content-Type": "application/json",
11608
- "Authorization": `Bearer ${hooksToken}`
11609
- },
11610
- body: JSON.stringify({
11611
- message: buildNotificationMessage(n),
11612
- name: "Cohort",
11613
- agentId,
11614
- deliver: false,
11615
- sessionKey: `hook:cohort:task-${n.taskNumber}`
11616
- })
11617
- });
11618
- if (!response.ok) {
11619
- throw new Error(`/hooks/agent returned ${response.status} ${response.statusText}`);
11775
+ // src/commands.ts
11776
+ var cronRunNowPoll = null;
11777
+ async function executeCommand(cmd, gwClient, cfg, resolveAgentName, logger) {
11778
+ if (cmd.type === "restart") {
11779
+ logger.info("cohort-sync: restart command, terminating in 500ms");
11780
+ await new Promise((r) => setTimeout(r, 500));
11781
+ process.kill(process.pid, "SIGTERM");
11782
+ return;
11620
11783
  }
11621
- }
11622
- var getUndeliveredForPlugin = makeFunctionReference("notifications:getUndeliveredForPlugin");
11623
- var markDeliveredByPlugin = makeFunctionReference("notifications:markDeliveredByPlugin");
11624
- var upsertTelemetryFromPlugin = makeFunctionReference("telemetryPlugin:upsertTelemetryFromPlugin");
11625
- var upsertSessionsFromPlugin = makeFunctionReference("telemetryPlugin:upsertSessionsFromPlugin");
11626
- var pushActivityFromPluginRef = makeFunctionReference("activityFeed:pushActivityFromPlugin");
11627
- var upsertCronSnapshotFromPluginRef = makeFunctionReference("telemetryPlugin:upsertCronSnapshotFromPlugin");
11628
- var getPendingCommandsForPlugin = makeFunctionReference("gatewayCommands:getPendingForPlugin");
11629
- var acknowledgeCommandRef = makeFunctionReference("gatewayCommands:acknowledgeCommand");
11630
- var addCommentFromPluginRef = makeFunctionReference("comments:addCommentFromPlugin");
11631
- var HOT_KEY = "__cohort_sync__";
11632
- function getHotState() {
11633
- let state = globalThis[HOT_KEY];
11634
- if (!state) {
11635
- state = {
11636
- tracker: null,
11637
- client: null,
11638
- convexUrl: null,
11639
- unsubscribers: [],
11640
- lastKnownRoster: [],
11641
- intervals: { heartbeat: null, activityFlush: null },
11642
- activityBuffer: [],
11643
- channelAgentBridge: {},
11644
- gatewayPort: null,
11645
- gatewayToken: null,
11646
- gatewayProtocolClient: null,
11647
- commandSubscription: null
11648
- };
11649
- globalThis[HOT_KEY] = state;
11784
+ if (cmd.type.startsWith("cron")) {
11785
+ if (!gwClient || !gwClient.isAlive()) {
11786
+ logger.warn(`cohort-sync: no gateway client, cannot execute ${cmd.type}`);
11787
+ return;
11788
+ }
11789
+ const nameMap = cfg.agentNameMap ?? {};
11790
+ switch (cmd.type) {
11791
+ case "cronEnable":
11792
+ await gwClient.request("cron.update", {
11793
+ jobId: cmd.payload?.jobId,
11794
+ patch: { enabled: true }
11795
+ });
11796
+ break;
11797
+ case "cronDisable":
11798
+ await gwClient.request("cron.update", {
11799
+ jobId: cmd.payload?.jobId,
11800
+ patch: { enabled: false }
11801
+ });
11802
+ break;
11803
+ case "cronDelete":
11804
+ await gwClient.request("cron.remove", {
11805
+ jobId: cmd.payload?.jobId
11806
+ });
11807
+ break;
11808
+ case "cronRunNow": {
11809
+ const runResult = await gwClient.request(
11810
+ "cron.run",
11811
+ { jobId: cmd.payload?.jobId }
11812
+ );
11813
+ if (runResult?.ok && runResult?.ran) {
11814
+ const jobId = cmd.payload?.jobId;
11815
+ let polls = 0;
11816
+ if (cronRunNowPoll) clearInterval(cronRunNowPoll);
11817
+ const pollInterval = setInterval(async () => {
11818
+ polls++;
11819
+ if (polls >= 15) {
11820
+ clearInterval(pollInterval);
11821
+ cronRunNowPoll = null;
11822
+ return;
11823
+ }
11824
+ try {
11825
+ if (!gwClient || !gwClient.isAlive()) {
11826
+ clearInterval(pollInterval);
11827
+ cronRunNowPoll = null;
11828
+ return;
11829
+ }
11830
+ const pollResult = await gwClient.request("cron.list");
11831
+ const freshJobs = Array.isArray(pollResult) ? pollResult : pollResult?.jobs ?? [];
11832
+ const job = freshJobs.find((j) => j.id === jobId);
11833
+ if (job && !job.state?.runningAtMs) {
11834
+ clearInterval(pollInterval);
11835
+ cronRunNowPoll = null;
11836
+ const mapped = freshJobs.map((j) => mapCronJob(j, resolveAgentName));
11837
+ await pushCronSnapshot(cfg.apiKey, mapped);
11838
+ }
11839
+ } catch {
11840
+ }
11841
+ }, 2e3);
11842
+ cronRunNowPoll = pollInterval;
11843
+ }
11844
+ break;
11845
+ }
11846
+ case "cronCreate": {
11847
+ const agentId = reverseResolveAgentName(cmd.payload?.agentId ?? "main", nameMap);
11848
+ await gwClient.request("cron.add", {
11849
+ job: {
11850
+ agentId,
11851
+ name: cmd.payload?.name,
11852
+ enabled: true,
11853
+ schedule: cmd.payload?.schedule,
11854
+ payload: { kind: "agentTurn", message: cmd.payload?.message },
11855
+ sessionTarget: "isolated",
11856
+ wakeMode: "now"
11857
+ }
11858
+ });
11859
+ break;
11860
+ }
11861
+ case "cronUpdate": {
11862
+ const patch = {};
11863
+ if (cmd.payload?.name) patch.name = cmd.payload.name;
11864
+ if (cmd.payload?.schedule) patch.schedule = cmd.payload.schedule;
11865
+ if (cmd.payload?.message) patch.payload = { kind: "agentTurn", message: cmd.payload.message };
11866
+ if (cmd.payload?.agentId) patch.agentId = reverseResolveAgentName(cmd.payload.agentId, nameMap);
11867
+ await gwClient.request("cron.update", {
11868
+ jobId: cmd.payload?.jobId,
11869
+ patch
11870
+ });
11871
+ break;
11872
+ }
11873
+ default:
11874
+ logger.warn(`cohort-sync: unknown cron command type: ${cmd.type}`);
11875
+ }
11876
+ if (gwClient.isAlive()) {
11877
+ try {
11878
+ const snapResult = await gwClient.request("cron.list");
11879
+ const freshJobs = Array.isArray(snapResult) ? snapResult : snapResult?.jobs ?? [];
11880
+ const mapped = freshJobs.map((j) => mapCronJob(j, resolveAgentName));
11881
+ await pushCronSnapshot(cfg.apiKey, mapped);
11882
+ } catch (snapErr) {
11883
+ logger.warn(`cohort-sync: post-command snapshot push failed: ${String(snapErr)}`);
11884
+ }
11885
+ }
11886
+ return;
11650
11887
  }
11651
- if (!state.activityBuffer) state.activityBuffer = [];
11652
- if (!state.intervals) state.intervals = { heartbeat: null, activityFlush: null };
11653
- if (!state.channelAgentBridge) state.channelAgentBridge = {};
11654
- if (!state.unsubscribers) state.unsubscribers = [];
11655
- if (!state.lastKnownRoster) state.lastKnownRoster = [];
11656
- return state;
11657
- }
11658
- function clearHotState() {
11659
- delete globalThis[HOT_KEY];
11660
- }
11661
- function setRosterHotState(roster) {
11662
- getHotState().lastKnownRoster = roster;
11888
+ logger.warn(`cohort-sync: unknown command type: ${cmd.type}`);
11663
11889
  }
11664
- function getRosterHotState() {
11665
- return getHotState().lastKnownRoster;
11890
+
11891
+ // src/convex-bridge.ts
11892
+ function deriveConvexUrl(apiUrl) {
11893
+ return apiUrl.replace(/\.convex\.site\/?$/, ".convex.cloud");
11666
11894
  }
11667
11895
  var savedLogger = null;
11668
11896
  function setLogger(logger) {
@@ -11678,266 +11906,18 @@ function getLogger() {
11678
11906
  var client = null;
11679
11907
  var savedConvexUrl = null;
11680
11908
  var unsubscribers = [];
11681
- function setConvexUrl(cfg) {
11682
- const url = cfg.convexUrl ?? deriveConvexUrl(cfg.apiUrl);
11683
- savedConvexUrl = url;
11684
- getHotState().convexUrl = url;
11685
- }
11686
- function restoreFromHotReload(logger) {
11687
- const state = getHotState();
11688
- if (!client && state.client) {
11689
- client = state.client;
11690
- savedConvexUrl = state.convexUrl;
11691
- logger.info("cohort-sync: recovered ConvexClient after hot-reload");
11692
- }
11693
- if (unsubscribers.length === 0 && state.unsubscribers.length > 0) {
11694
- unsubscribers.push(...state.unsubscribers);
11695
- logger.info(`cohort-sync: recovered ${state.unsubscribers.length} notification subscriptions after hot-reload`);
11696
- }
11697
- }
11698
- function getOrCreateClient() {
11699
- if (client) return client;
11700
- const state = getHotState();
11701
- if (state.client) {
11702
- client = state.client;
11703
- getLogger().info("cohort-sync: recovered ConvexClient from globalThis");
11704
- return client;
11705
- }
11706
- if (!savedConvexUrl) return null;
11707
- client = new ConvexClient(savedConvexUrl);
11708
- getLogger().info(`cohort-sync: created fresh ConvexClient (${savedConvexUrl})`);
11709
- state.client = client;
11710
- state.convexUrl = savedConvexUrl;
11711
- return client;
11712
- }
11713
- async function initSubscription(port, cfg, hooksToken, logger) {
11714
- const convexUrl = cfg.convexUrl ?? deriveConvexUrl(cfg.apiUrl);
11715
- savedConvexUrl = convexUrl;
11716
- const state = getHotState();
11717
- if (state.client) {
11718
- client = state.client;
11719
- logger.info("cohort-sync: reusing hot-reload ConvexClient for subscription");
11720
- } else {
11721
- client = new ConvexClient(convexUrl);
11722
- logger.info(`cohort-sync: created new ConvexClient for subscription (${convexUrl})`);
11909
+ function createClient(convexUrl) {
11910
+ if (client) {
11911
+ client.close();
11723
11912
  }
11724
- state.client = client;
11725
- state.convexUrl = convexUrl;
11726
- if (!hooksToken) {
11727
- logger.warn(
11728
- `cohort-sync: hooks.token not configured \u2014 real-time notifications disabled (telemetry will still be pushed).`
11729
- );
11730
- return;
11731
- }
11732
- const agentNames = cfg.agentNameMap ? Object.values(cfg.agentNameMap) : ["main"];
11733
- const reverseNameMap = {};
11734
- if (cfg.agentNameMap) {
11735
- for (const [openclawId, cohortName] of Object.entries(cfg.agentNameMap)) {
11736
- reverseNameMap[cohortName] = openclawId;
11737
- }
11738
- }
11739
- for (const agentName of agentNames) {
11740
- const openclawAgentId = reverseNameMap[agentName] ?? agentName;
11741
- logger.info(`cohort-sync: subscribing to notifications for agent "${agentName}" (openclawId: "${openclawAgentId}")`);
11742
- let processing = false;
11743
- const unsubscribe = client.onUpdate(
11744
- getUndeliveredForPlugin,
11745
- { agent: agentName, apiKey: cfg.apiKey },
11746
- async (notifications) => {
11747
- if (processing) return;
11748
- processing = true;
11749
- try {
11750
- for (const n of notifications) {
11751
- try {
11752
- await injectNotification(port, hooksToken, n, openclawAgentId);
11753
- logger.info(`cohort-sync: injected notification for task #${n.taskNumber} (${agentName})`);
11754
- await client.mutation(markDeliveredByPlugin, {
11755
- notificationId: n._id,
11756
- apiKey: cfg.apiKey
11757
- });
11758
- } catch (err) {
11759
- logger.warn(`cohort-sync: failed to inject notification ${n._id}: ${String(err)}`);
11760
- }
11761
- }
11762
- } finally {
11763
- processing = false;
11764
- }
11765
- },
11766
- (err) => {
11767
- logger.error(`cohort-sync: subscription error for "${agentName}": ${String(err)}`);
11768
- }
11769
- );
11770
- unsubscribers.push(unsubscribe);
11771
- }
11772
- state.unsubscribers = [...unsubscribers];
11773
- }
11774
- function initCommandSubscription(cfg, logger, resolveAgentName) {
11775
- const c = getOrCreateClient();
11776
- if (!c) {
11777
- logger.warn("cohort-sync: no ConvexClient \u2014 command subscription skipped");
11778
- return null;
11779
- }
11780
- let processing = false;
11781
- const unsubscribe = c.onUpdate(
11782
- getPendingCommandsForPlugin,
11783
- { apiKey: cfg.apiKey },
11784
- async (commands) => {
11785
- if (processing) return;
11786
- if (commands.length === 0) return;
11787
- processing = true;
11788
- try {
11789
- for (const cmd of commands) {
11790
- logger.info(`cohort-sync: processing command: ${cmd.type} (${cmd._id})`);
11791
- try {
11792
- await c.mutation(acknowledgeCommandRef, {
11793
- commandId: cmd._id,
11794
- apiKey: cfg.apiKey
11795
- });
11796
- if (cmd.type === "restart") {
11797
- logger.info("cohort-sync: restart acknowledged, terminating in 500ms");
11798
- await new Promise((r) => setTimeout(r, 500));
11799
- process.kill(process.pid, "SIGTERM");
11800
- return;
11801
- }
11802
- if (cmd.type.startsWith("cron")) {
11803
- const hotState = getHotState();
11804
- const port = hotState.gatewayPort;
11805
- const token = hotState.gatewayToken;
11806
- if (!port || !token) {
11807
- logger.warn(`cohort-sync: no gateway port/token, cannot execute ${cmd.type}`);
11808
- continue;
11809
- }
11810
- const gwClient = new GatewayClient(port, token, logger);
11811
- try {
11812
- await gwClient.connect();
11813
- const nameMap = cfg.agentNameMap ?? {};
11814
- switch (cmd.type) {
11815
- case "cronEnable":
11816
- await gwClient.request("cron.update", {
11817
- jobId: cmd.payload?.jobId,
11818
- patch: { enabled: true }
11819
- });
11820
- break;
11821
- case "cronDisable":
11822
- await gwClient.request("cron.update", {
11823
- jobId: cmd.payload?.jobId,
11824
- patch: { enabled: false }
11825
- });
11826
- break;
11827
- case "cronDelete":
11828
- await gwClient.request("cron.remove", {
11829
- jobId: cmd.payload?.jobId
11830
- });
11831
- break;
11832
- case "cronRunNow": {
11833
- const runResult = await gwClient.request(
11834
- "cron.run",
11835
- { jobId: cmd.payload?.jobId }
11836
- );
11837
- if (runResult?.ok && runResult?.ran) {
11838
- const jobId = cmd.payload?.jobId;
11839
- let polls = 0;
11840
- const pollInterval = setInterval(async () => {
11841
- polls++;
11842
- if (polls >= 15) {
11843
- clearInterval(pollInterval);
11844
- return;
11845
- }
11846
- try {
11847
- const pollClient = getHotState().gatewayProtocolClient;
11848
- if (!pollClient || !pollClient.isAlive()) {
11849
- clearInterval(pollInterval);
11850
- return;
11851
- }
11852
- const pollResult = await pollClient.request("cron.list");
11853
- const freshJobs = Array.isArray(pollResult) ? pollResult : pollResult?.jobs ?? [];
11854
- const job = freshJobs.find((j) => j.id === jobId);
11855
- if (job && !job.state?.runningAtMs) {
11856
- clearInterval(pollInterval);
11857
- const mapped = freshJobs.map((j) => mapCronJob(j, resolveAgentName));
11858
- await pushCronSnapshot(cfg.apiKey, mapped);
11859
- }
11860
- } catch {
11861
- }
11862
- }, 2e3);
11863
- }
11864
- break;
11865
- }
11866
- case "cronCreate": {
11867
- const agentId = reverseResolveAgentName(cmd.payload?.agentId ?? "main", nameMap);
11868
- await gwClient.request("cron.add", {
11869
- job: {
11870
- agentId,
11871
- name: cmd.payload?.name,
11872
- enabled: true,
11873
- schedule: cmd.payload?.schedule,
11874
- payload: { kind: "agentTurn", message: cmd.payload?.message },
11875
- sessionTarget: "isolated",
11876
- wakeMode: "now"
11877
- }
11878
- });
11879
- break;
11880
- }
11881
- case "cronUpdate": {
11882
- const patch = {};
11883
- if (cmd.payload?.name) patch.name = cmd.payload.name;
11884
- if (cmd.payload?.schedule) patch.schedule = cmd.payload.schedule;
11885
- if (cmd.payload?.message) patch.payload = { kind: "agentTurn", message: cmd.payload.message };
11886
- if (cmd.payload?.agentId) patch.agentId = reverseResolveAgentName(cmd.payload.agentId, nameMap);
11887
- await gwClient.request("cron.update", {
11888
- jobId: cmd.payload?.jobId,
11889
- patch
11890
- });
11891
- break;
11892
- }
11893
- default:
11894
- logger.warn(`cohort-sync: unknown cron command type: ${cmd.type}`);
11895
- }
11896
- if (gwClient.isAlive()) {
11897
- try {
11898
- const snapResult = await gwClient.request("cron.list");
11899
- const freshJobs = Array.isArray(snapResult) ? snapResult : snapResult?.jobs ?? [];
11900
- const mapped = freshJobs.map((j) => mapCronJob(j, resolveAgentName));
11901
- await pushCronSnapshot(cfg.apiKey, mapped);
11902
- } catch (snapErr) {
11903
- logger.warn(`cohort-sync: post-command snapshot push failed: ${String(snapErr)}`);
11904
- }
11905
- }
11906
- } finally {
11907
- gwClient.close();
11908
- }
11909
- } else {
11910
- logger.warn(`cohort-sync: unknown command type: ${cmd.type}`);
11911
- }
11912
- } catch (err) {
11913
- logger.error(`cohort-sync: failed to process command ${cmd._id}: ${String(err)}`);
11914
- }
11915
- }
11916
- } finally {
11917
- processing = false;
11918
- }
11919
- },
11920
- (err) => {
11921
- logger.error(`cohort-sync: command subscription error: ${String(err)}`);
11922
- }
11923
- );
11924
- logger.info("cohort-sync: command subscription active");
11925
- return unsubscribe;
11913
+ savedConvexUrl = convexUrl;
11914
+ client = new ConvexClient(convexUrl);
11915
+ return client;
11926
11916
  }
11927
- async function callAddCommentFromPlugin(apiKey, args) {
11928
- const c = getOrCreateClient();
11929
- if (!c) {
11930
- throw new Error("Convex client not initialized \u2014 subscription may not be active");
11931
- }
11932
- return await c.mutation(addCommentFromPluginRef, {
11933
- apiKey,
11934
- taskNumber: args.taskNumber,
11935
- agentName: args.agentName,
11936
- content: args.content,
11937
- noReply: args.noReply
11938
- });
11917
+ function getClient() {
11918
+ return client;
11939
11919
  }
11940
- function closeSubscription() {
11920
+ function closeBridge() {
11941
11921
  for (const unsub of unsubscribers) {
11942
11922
  try {
11943
11923
  unsub();
@@ -11945,33 +11925,32 @@ function closeSubscription() {
11945
11925
  }
11946
11926
  }
11947
11927
  unsubscribers.length = 0;
11948
- const state = getHotState();
11949
- for (const unsub of state.unsubscribers) {
11928
+ if (commandUnsubscriber) {
11950
11929
  try {
11951
- unsub();
11930
+ commandUnsubscriber();
11952
11931
  } catch {
11953
11932
  }
11933
+ commandUnsubscriber = null;
11954
11934
  }
11955
- if (state.commandSubscription) {
11956
- try {
11957
- state.commandSubscription();
11958
- } catch {
11959
- }
11960
- state.commandSubscription = null;
11961
- }
11962
- if (state.gatewayProtocolClient) {
11963
- try {
11964
- state.gatewayProtocolClient.close();
11965
- } catch {
11966
- }
11967
- state.gatewayProtocolClient = null;
11935
+ if (client) {
11936
+ client.close();
11937
+ client = null;
11968
11938
  }
11969
- client?.close();
11970
- client = null;
11971
- clearHotState();
11939
+ savedConvexUrl = null;
11972
11940
  }
11941
+ var commandUnsubscriber = null;
11942
+ var upsertTelemetryFromPlugin = makeFunctionReference("telemetryPlugin:upsertTelemetryFromPlugin");
11943
+ var upsertSessionsFromPlugin = makeFunctionReference("telemetryPlugin:upsertSessionsFromPlugin");
11944
+ var pushActivityFromPluginRef = makeFunctionReference("activityFeed:pushActivityFromPlugin");
11945
+ var upsertCronSnapshotFromPluginRef = makeFunctionReference("telemetryPlugin:upsertCronSnapshotFromPlugin");
11946
+ var getUndeliveredForPlugin = makeFunctionReference("notifications:getUndeliveredForPlugin");
11947
+ var markDeliveredByPlugin = makeFunctionReference("notifications:markDeliveredByPlugin");
11948
+ var getPendingCommandsForPlugin = makeFunctionReference("gatewayCommands:getPendingForPlugin");
11949
+ var acknowledgeCommandRef = makeFunctionReference("gatewayCommands:acknowledgeCommand");
11950
+ var failCommandRef = makeFunctionReference("gatewayCommands:failCommand");
11951
+ var addCommentFromPluginRef = makeFunctionReference("comments:addCommentFromPlugin");
11973
11952
  async function pushTelemetry(apiKey, data) {
11974
- const c = getOrCreateClient();
11953
+ const c = getClient();
11975
11954
  if (!c) return;
11976
11955
  try {
11977
11956
  await c.mutation(upsertTelemetryFromPlugin, { apiKey, ...data });
@@ -11980,7 +11959,7 @@ async function pushTelemetry(apiKey, data) {
11980
11959
  }
11981
11960
  }
11982
11961
  async function pushSessions(apiKey, agentName, sessions) {
11983
- const c = getOrCreateClient();
11962
+ const c = getClient();
11984
11963
  if (!c) return;
11985
11964
  try {
11986
11965
  await c.mutation(upsertSessionsFromPlugin, { apiKey, agentName, sessions });
@@ -11990,7 +11969,7 @@ async function pushSessions(apiKey, agentName, sessions) {
11990
11969
  }
11991
11970
  async function pushActivity(apiKey, entries) {
11992
11971
  if (entries.length === 0) return;
11993
- const c = getOrCreateClient();
11972
+ const c = getClient();
11994
11973
  if (!c) return;
11995
11974
  try {
11996
11975
  await c.mutation(pushActivityFromPluginRef, { apiKey, entries });
@@ -11999,271 +11978,203 @@ async function pushActivity(apiKey, entries) {
11999
11978
  }
12000
11979
  }
12001
11980
  async function pushCronSnapshot(apiKey, jobs) {
12002
- const c = getOrCreateClient();
12003
- if (!c) return;
11981
+ const c = getClient();
11982
+ if (!c) return false;
12004
11983
  try {
12005
11984
  await c.mutation(upsertCronSnapshotFromPluginRef, { apiKey, jobs });
11985
+ return true;
12006
11986
  } catch (err) {
12007
11987
  getLogger().error(`cohort-sync: pushCronSnapshot failed: ${err}`);
11988
+ return false;
12008
11989
  }
12009
11990
  }
12010
- function saveIntervalsToHot(heartbeat, activityFlush) {
12011
- const state = getHotState();
12012
- state.intervals = { heartbeat, activityFlush };
12013
- }
12014
- function clearIntervalsFromHot() {
12015
- const state = getHotState();
12016
- if (state.intervals.heartbeat) clearInterval(state.intervals.heartbeat);
12017
- if (state.intervals.activityFlush) clearInterval(state.intervals.activityFlush);
12018
- state.intervals = { heartbeat: null, activityFlush: null };
12019
- }
12020
- function addActivityToHot(entry) {
12021
- const state = getHotState();
12022
- state.activityBuffer.push(entry);
12023
- const logger = savedLogger;
12024
- if (logger) {
12025
- logger.info(`cohort-sync: +activity [${entry.category}] "${entry.text}"`);
12026
- }
12027
- }
12028
- function drainActivityFromHot() {
12029
- const state = getHotState();
12030
- const buf = state.activityBuffer;
12031
- state.activityBuffer = [];
12032
- return buf;
12033
- }
12034
- function setChannelAgent(channelId, agentName) {
12035
- getHotState().channelAgentBridge[channelId] = agentName;
12036
- }
12037
- function getChannelAgent(channelId) {
12038
- return getHotState().channelAgentBridge[channelId] ?? null;
12039
- }
12040
-
12041
- // src/sync.ts
12042
- function extractJson(raw) {
12043
- const jsonStart = raw.search(/[\[{]/);
12044
- const jsonEndBracket = raw.lastIndexOf("]");
12045
- const jsonEndBrace = raw.lastIndexOf("}");
12046
- const jsonEnd = Math.max(jsonEndBracket, jsonEndBrace);
12047
- if (jsonStart === -1 || jsonEnd === -1 || jsonEnd < jsonStart) {
12048
- throw new Error("No JSON found in output");
12049
- }
12050
- return raw.slice(jsonStart, jsonEnd + 1);
12051
- }
12052
- function fetchSkills(logger) {
12053
- try {
12054
- const raw = execSync("openclaw skills list --json", {
12055
- encoding: "utf8",
12056
- timeout: 3e4,
12057
- stdio: ["ignore", "pipe", "ignore"],
12058
- env: { ...process.env, NO_COLOR: "1" }
12059
- });
12060
- const parsed = JSON.parse(extractJson(raw));
12061
- const list = Array.isArray(parsed) ? parsed : parsed?.skills ?? [];
12062
- return list.map((s) => ({
12063
- name: String(s.name ?? s.id ?? "unknown"),
12064
- description: String(s.description ?? ""),
12065
- source: String(s.source ?? s.origin ?? "unknown"),
12066
- ...s.emoji ? { emoji: String(s.emoji) } : {}
12067
- }));
12068
- } catch (err) {
12069
- logger.warn(`cohort-sync: failed to fetch skills: ${String(err)}`);
12070
- return [];
11991
+ async function callAddCommentFromPlugin(apiKey, args) {
11992
+ const c = getClient();
11993
+ if (!c) {
11994
+ throw new Error("Convex client not initialized \u2014 subscription may not be active");
12071
11995
  }
12072
- }
12073
- var VALID_STATUSES = /* @__PURE__ */ new Set(["idle", "working", "waiting"]);
12074
- function normalizeStatus(status) {
12075
- return VALID_STATUSES.has(status) ? status : "idle";
12076
- }
12077
- async function v1Get(apiUrl, apiKey, path4) {
12078
- const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
12079
- headers: { Authorization: `Bearer ${apiKey}` },
12080
- signal: AbortSignal.timeout(1e4)
12081
- });
12082
- if (!res.ok) throw new Error(`GET ${path4} \u2192 ${res.status}`);
12083
- return res.json();
12084
- }
12085
- async function v1Patch(apiUrl, apiKey, path4, body) {
12086
- const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
12087
- method: "PATCH",
12088
- headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
12089
- body: JSON.stringify(body),
12090
- signal: AbortSignal.timeout(1e4)
11996
+ return await c.mutation(addCommentFromPluginRef, {
11997
+ apiKey,
11998
+ taskNumber: args.taskNumber,
11999
+ agentName: args.agentName,
12000
+ content: args.content,
12001
+ noReply: args.noReply
12091
12002
  });
12092
- if (!res.ok) throw new Error(`PATCH ${path4} \u2192 ${res.status}`);
12093
12003
  }
12094
- async function v1Post(apiUrl, apiKey, path4, body) {
12095
- const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
12096
- method: "POST",
12097
- headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
12098
- body: JSON.stringify(body),
12099
- signal: AbortSignal.timeout(1e4)
12100
- });
12101
- if (!res.ok) throw new Error(`POST ${path4} \u2192 ${res.status}`);
12102
- }
12103
- async function checkForUpdate(currentVersion, logger) {
12104
- try {
12105
- const res = await fetch("https://registry.npmjs.org/@cfio/cohort-sync/latest", {
12106
- signal: AbortSignal.timeout(5e3)
12107
- });
12108
- if (!res.ok) return;
12109
- const data = await res.json();
12110
- const latest = data.version;
12111
- if (latest && latest !== currentVersion) {
12112
- logger.warn(
12113
- `cohort-sync: update available (${currentVersion} \u2192 ${latest}) \u2014 run "npm install -g @cfio/cohort-sync" to update`
12114
- );
12115
- }
12116
- } catch {
12117
- }
12118
- }
12119
- async function syncAgentStatus(agentName, status, model, cfg, logger) {
12120
- try {
12121
- const data = await v1Get(cfg.apiUrl, cfg.apiKey, "/api/v1/agents?limit=100");
12122
- const agents = data?.data ?? [];
12123
- const agent = agents.find(
12124
- (a) => a.name.toLowerCase() === agentName.toLowerCase()
12125
- );
12126
- if (!agent) {
12127
- const available = agents.map((a) => a.name).join(", ") || "(none)";
12128
- logger.warn(
12129
- `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.`
12130
- );
12131
- return;
12132
- }
12133
- await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${agent.id}`, {
12134
- status: normalizeStatus(status),
12135
- model
12136
- });
12137
- logger.info(`cohort-sync: agent "${agentName}" \u2192 ${normalizeStatus(status)}`);
12138
- } catch (err) {
12139
- logger.warn(`cohort-sync: syncAgentStatus failed: ${String(err)}`);
12140
- }
12141
- }
12142
- async function syncSkillsToV1(skills, cfg, logger) {
12143
- for (const skill of skills) {
12144
- try {
12145
- await v1Post(cfg.apiUrl, cfg.apiKey, "/api/v1/skills", {
12146
- name: skill.name,
12147
- description: skill.description
12148
- });
12149
- } catch (err) {
12150
- logger.warn(`cohort-sync: failed to sync skill "${skill.name}": ${String(err)}`);
12151
- }
12004
+ var DEFAULT_BEHAVIORAL_PROMPT = `BEFORE RESPONDING:
12005
+ - Does your planned response address the task's stated scope? If not, do not comment.
12006
+ - Do not post acknowledgment-only responses ("got it", "sounds good", "confirmed"). If you have no new information to add, do not comment.
12007
+ - If the work is complete, transition the task to "waiting" and set noReply=true on your final comment, then stop working on this task.`;
12008
+ function buildNotificationMessage(n) {
12009
+ let header;
12010
+ let cta;
12011
+ switch (n.type) {
12012
+ case "comment":
12013
+ if (n.isMentioned) {
12014
+ header = `You were @mentioned on task #${n.taskNumber} "${n.taskTitle}"
12015
+ By: ${n.actorName}`;
12016
+ cta = "You were directly mentioned. Read the comment and respond using the cohort_comment tool.";
12017
+ } else {
12018
+ header = `New comment on task #${n.taskNumber} "${n.taskTitle}"
12019
+ From: ${n.actorName}`;
12020
+ cta = "Read the comment thread and respond using the cohort_comment tool if appropriate.";
12021
+ }
12022
+ break;
12023
+ case "assignment":
12024
+ header = `You were assigned to task #${n.taskNumber} "${n.taskTitle}"
12025
+ By: ${n.actorName}`;
12026
+ cta = "Review the task description and begin working on it.";
12027
+ break;
12028
+ case "status_change":
12029
+ header = `Status changed on task #${n.taskNumber} "${n.taskTitle}"
12030
+ By: ${n.actorName}`;
12031
+ cta = "Review the status change and take any follow-up action needed.";
12032
+ break;
12033
+ default:
12034
+ header = `Notification on task #${n.taskNumber} "${n.taskTitle}"
12035
+ From: ${n.actorName}`;
12036
+ cta = "Check the task and respond if needed.";
12152
12037
  }
12153
- }
12154
- var lastKnownRoster = [];
12155
- function getLastKnownRoster() {
12156
- return lastKnownRoster;
12157
- }
12158
- function restoreRosterFromHotReload(hotRoster, logger) {
12159
- if (hotRoster && hotRoster.length > 0 && lastKnownRoster.length === 0) {
12160
- lastKnownRoster = hotRoster;
12161
- logger.info(`cohort-sync: recovered roster (${hotRoster.length} agents) after hot-reload`);
12038
+ const body = n.preview ? `
12039
+ Comment: "${n.preview}"` : "";
12040
+ let scope = "";
12041
+ if (n.taskDescription && n.type === "comment") {
12042
+ const truncated = n.taskDescription.length > 500 ? n.taskDescription.slice(0, 500) + "..." : n.taskDescription;
12043
+ scope = `
12044
+
12045
+ Scope: ${truncated}`;
12162
12046
  }
12047
+ const prompt = n.behavioralPrompt ? `${n.behavioralPrompt}
12048
+
12049
+ ${DEFAULT_BEHAVIORAL_PROMPT}` : DEFAULT_BEHAVIORAL_PROMPT;
12050
+ const promptBlock = n.type === "comment" ? `
12051
+
12052
+ ---
12053
+ ${prompt}` : "";
12054
+ return `${header}${scope}${body}
12055
+
12056
+ ${cta}${promptBlock}`;
12163
12057
  }
12164
- async function reconcileRoster(openClawAgents, cfg, logger) {
12165
- const data = await v1Get(cfg.apiUrl, cfg.apiKey, "/api/v1/agents?limit=100");
12166
- const cohortAgents = data?.data ?? [];
12167
- const cohortByName = new Map(cohortAgents.map((a) => [a.name.toLowerCase(), a]));
12168
- const openClawNames = new Set(
12169
- openClawAgents.map((a) => {
12170
- const nameMap = cfg.agentNameMap;
12171
- return (nameMap?.[a.id] ?? a.identity?.name ?? a.id).toLowerCase();
12172
- })
12173
- );
12174
- for (const oc of openClawAgents) {
12175
- const agentName = (cfg.agentNameMap?.[oc.id] ?? oc.identity?.name ?? oc.id).toLowerCase();
12176
- const existing = cohortByName.get(agentName);
12177
- if (!existing) {
12178
- try {
12179
- await v1Post(cfg.apiUrl, cfg.apiKey, "/api/v1/agents", {
12180
- name: agentName,
12181
- displayName: oc.identity?.name ?? agentName,
12182
- emoji: oc.identity?.emoji ?? "\u{1F916}",
12183
- model: oc.model,
12184
- status: "idle"
12185
- });
12186
- logger.info(`cohort-sync: provisioned new agent "${agentName}"`);
12187
- } catch (err) {
12188
- logger.warn(`cohort-sync: failed to provision agent "${agentName}": ${String(err)}`);
12189
- }
12190
- } else {
12191
- const updates = {
12192
- model: oc.model,
12193
- status: "idle"
12194
- };
12195
- if (oc.identity?.name) {
12196
- updates.displayName = oc.identity.name;
12197
- }
12198
- if (oc.identity?.emoji) {
12199
- updates.emoji = oc.identity.emoji;
12200
- }
12201
- try {
12202
- await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${existing.id}`, updates);
12203
- } catch (err) {
12204
- logger.warn(`cohort-sync: failed to update agent "${agentName}": ${String(err)}`);
12205
- }
12206
- }
12207
- }
12208
- for (const cohort of cohortAgents) {
12209
- if (!openClawNames.has(cohort.name.toLowerCase())) {
12210
- if (cohort.status === "archived" || cohort.status === "deleted" || cohort.status === "unreachable") {
12211
- continue;
12212
- }
12213
- try {
12214
- await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${cohort.id}`, {
12215
- status: "unreachable"
12216
- });
12217
- logger.info(`cohort-sync: marked agent "${cohort.name}" as unreachable`);
12218
- } catch (err) {
12219
- logger.warn(
12220
- `cohort-sync: failed to mark agent "${cohort.name}" as unreachable: ${String(err)}`
12221
- );
12222
- }
12223
- }
12058
+ async function injectNotification(port, hooksToken, n, agentId = "main") {
12059
+ const response = await fetch(`http://localhost:${port}/hooks/agent`, {
12060
+ method: "POST",
12061
+ headers: {
12062
+ "Content-Type": "application/json",
12063
+ "Authorization": `Bearer ${hooksToken}`
12064
+ },
12065
+ body: JSON.stringify({
12066
+ message: buildNotificationMessage(n),
12067
+ name: "Cohort",
12068
+ agentId,
12069
+ deliver: false,
12070
+ sessionKey: `hook:cohort:task-${n.taskNumber}`
12071
+ })
12072
+ });
12073
+ if (!response.ok) {
12074
+ throw new Error(`/hooks/agent returned ${response.status} ${response.statusText}`);
12224
12075
  }
12225
- const updatedData = await v1Get(cfg.apiUrl, cfg.apiKey, "/api/v1/agents?limit=100");
12226
- const finalRoster = updatedData?.data ?? cohortAgents;
12227
- lastKnownRoster = finalRoster;
12228
- setRosterHotState(finalRoster);
12229
- return finalRoster;
12230
12076
  }
12231
- async function markAllUnreachable(cfg, logger) {
12232
- const roster = getLastKnownRoster();
12233
- if (roster.length === 0) {
12234
- logger.warn("cohort-sync: no cached roster \u2014 skipping markAllUnreachable");
12077
+ async function startNotificationSubscription(port, cfg, hooksToken, logger) {
12078
+ const c = getClient();
12079
+ if (!c) {
12080
+ logger.warn("cohort-sync: no ConvexClient \u2014 notification subscription skipped");
12235
12081
  return;
12236
12082
  }
12237
- for (const agent of roster) {
12238
- if (agent.status === "unreachable" || agent.status === "archived" || agent.status === "deleted") {
12239
- continue;
12240
- }
12241
- try {
12242
- await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${agent.id}`, {
12243
- status: "unreachable"
12244
- });
12245
- } catch (err) {
12246
- logger.warn(`cohort-sync: failed to mark "${agent.name}" as unreachable: ${String(err)}`);
12247
- }
12083
+ if (!hooksToken) {
12084
+ logger.warn(
12085
+ `cohort-sync: hooks.token not configured \u2014 real-time notifications disabled (telemetry will still be pushed).`
12086
+ );
12087
+ return;
12248
12088
  }
12249
- logger.info("cohort-sync: all agents marked unreachable");
12250
- }
12251
- async function fullSync(agentName, model, cfg, logger, openClawAgents) {
12252
- logger.info("cohort-sync: full sync starting");
12253
- if (openClawAgents && openClawAgents.length > 0) {
12254
- try {
12255
- await reconcileRoster(openClawAgents, cfg, logger);
12256
- } catch (err) {
12257
- logger.warn(`cohort-sync: roster reconciliation failed: ${String(err)}`);
12089
+ const agentNames = cfg.agentNameMap ? Object.values(cfg.agentNameMap) : ["main"];
12090
+ const reverseNameMap = {};
12091
+ if (cfg.agentNameMap) {
12092
+ for (const [openclawId, cohortName] of Object.entries(cfg.agentNameMap)) {
12093
+ reverseNameMap[cohortName] = openclawId;
12258
12094
  }
12259
- } else {
12260
- await syncAgentStatus(agentName, "working", model, cfg, logger);
12261
12095
  }
12262
- const skills = fetchSkills(logger);
12263
- if (skills.length > 0) {
12264
- await syncSkillsToV1(skills, cfg, logger);
12096
+ for (const agentName of agentNames) {
12097
+ const openclawAgentId = reverseNameMap[agentName] ?? agentName;
12098
+ logger.info(`cohort-sync: subscribing to notifications for agent "${agentName}" (openclawId: "${openclawAgentId}")`);
12099
+ let processing = false;
12100
+ const unsubscribe = c.onUpdate(
12101
+ getUndeliveredForPlugin,
12102
+ { agent: agentName, apiKey: cfg.apiKey },
12103
+ async (notifications) => {
12104
+ if (processing) return;
12105
+ processing = true;
12106
+ try {
12107
+ for (const n of notifications) {
12108
+ try {
12109
+ await injectNotification(port, hooksToken, n, openclawAgentId);
12110
+ logger.info(`cohort-sync: injected notification for task #${n.taskNumber} (${agentName})`);
12111
+ await c.mutation(markDeliveredByPlugin, {
12112
+ notificationId: n._id,
12113
+ apiKey: cfg.apiKey
12114
+ });
12115
+ } catch (err) {
12116
+ logger.warn(`cohort-sync: failed to inject notification ${n._id}: ${String(err)}`);
12117
+ }
12118
+ }
12119
+ } finally {
12120
+ processing = false;
12121
+ }
12122
+ },
12123
+ (err) => {
12124
+ logger.error(`cohort-sync: subscription error for "${agentName}": ${String(err)}`);
12125
+ }
12126
+ );
12127
+ unsubscribers.push(unsubscribe);
12265
12128
  }
12266
- logger.info("cohort-sync: full sync complete");
12129
+ }
12130
+ function startCommandSubscription(cfg, logger, resolveAgentName, gwClient) {
12131
+ const c = getClient();
12132
+ if (!c) {
12133
+ logger.warn("cohort-sync: no ConvexClient \u2014 command subscription skipped");
12134
+ return null;
12135
+ }
12136
+ let processing = false;
12137
+ const unsubscribe = c.onUpdate(
12138
+ getPendingCommandsForPlugin,
12139
+ { apiKey: cfg.apiKey },
12140
+ async (commands) => {
12141
+ if (processing) return;
12142
+ if (commands.length === 0) return;
12143
+ processing = true;
12144
+ try {
12145
+ for (const cmd of commands) {
12146
+ logger.info(`cohort-sync: processing command: ${cmd.type} (${cmd._id})`);
12147
+ try {
12148
+ await c.mutation(acknowledgeCommandRef, {
12149
+ commandId: cmd._id,
12150
+ apiKey: cfg.apiKey
12151
+ });
12152
+ await executeCommand(cmd, gwClient, cfg, resolveAgentName, logger);
12153
+ if (cmd.type === "restart") return;
12154
+ } catch (err) {
12155
+ logger.error(`cohort-sync: failed to process command ${cmd._id}: ${String(err)}`);
12156
+ try {
12157
+ await c.mutation(failCommandRef, {
12158
+ commandId: cmd._id,
12159
+ apiKey: cfg.apiKey,
12160
+ reason: String(err).slice(0, 500)
12161
+ });
12162
+ } catch (failErr) {
12163
+ logger.error(`cohort-sync: failed to mark command ${cmd._id} as failed: ${String(failErr)}`);
12164
+ }
12165
+ }
12166
+ }
12167
+ } finally {
12168
+ processing = false;
12169
+ }
12170
+ },
12171
+ (err) => {
12172
+ logger.error(`cohort-sync: command subscription error: ${String(err)}`);
12173
+ }
12174
+ );
12175
+ commandUnsubscriber = unsubscribe;
12176
+ logger.info("cohort-sync: command subscription active");
12177
+ return unsubscribe;
12267
12178
  }
12268
12179
 
12269
12180
  // src/gateway-client.ts
@@ -12450,13 +12361,7 @@ function parseHelloOk(response) {
12450
12361
  snapshot: payload.snapshot
12451
12362
  };
12452
12363
  }
12453
- function getPendingRequests() {
12454
- const g = globalThis;
12455
- const hot = g.__cohort_sync__ ?? (g.__cohort_sync__ = {});
12456
- if (!hot.pendingGatewayRequests) hot.pendingGatewayRequests = /* @__PURE__ */ new Map();
12457
- return hot.pendingGatewayRequests;
12458
- }
12459
- var GatewayClient2 = class {
12364
+ var GatewayClient = class {
12460
12365
  port;
12461
12366
  logger;
12462
12367
  ws = null;
@@ -12470,9 +12375,12 @@ var GatewayClient2 = class {
12470
12375
  tickIntervalMs = 15e3;
12471
12376
  // default; overwritten by hello-ok response
12472
12377
  deviceIdentity;
12378
+ pendingRequests = /* @__PURE__ */ new Map();
12473
12379
  /** Public Sets populated from hello-ok — consumers can inspect gateway capabilities */
12474
12380
  availableMethods = /* @__PURE__ */ new Set();
12475
12381
  availableEvents = /* @__PURE__ */ new Set();
12382
+ /** Called after a successful reconnection (WebSocket re-established after drop) */
12383
+ onReconnect = null;
12476
12384
  /**
12477
12385
  * @param port - Gateway WebSocket port
12478
12386
  * @param token - Auth token (stored in closure via constructor param, NOT on this or globalThis)
@@ -12599,12 +12507,11 @@ var GatewayClient2 = class {
12599
12507
  this.stopTickWatchdog();
12600
12508
  diag("GW_CLIENT_WS_CLOSED", { port: this.port });
12601
12509
  this.logger.warn("cohort-sync: gateway client WebSocket closed");
12602
- const pending = getPendingRequests();
12603
- for (const [, entry] of pending) {
12510
+ for (const [, entry] of this.pendingRequests) {
12604
12511
  clearTimeout(entry.timer);
12605
12512
  entry.reject(new Error("Gateway WebSocket closed"));
12606
12513
  }
12607
- pending.clear();
12514
+ this.pendingRequests.clear();
12608
12515
  if (!settled) {
12609
12516
  settle(new Error("Gateway WebSocket closed during handshake"));
12610
12517
  }
@@ -12635,6 +12542,18 @@ var GatewayClient2 = class {
12635
12542
  }
12636
12543
  handlers.add(handler);
12637
12544
  }
12545
+ /**
12546
+ * Remove an event handler previously registered with on().
12547
+ */
12548
+ off(event, handler) {
12549
+ const handlers = this.eventHandlers.get(event);
12550
+ if (handlers) {
12551
+ handlers.delete(handler);
12552
+ if (handlers.size === 0) {
12553
+ this.eventHandlers.delete(event);
12554
+ }
12555
+ }
12556
+ }
12638
12557
  /**
12639
12558
  * Send a request to the gateway and wait for the response.
12640
12559
  *
@@ -12655,13 +12574,12 @@ var GatewayClient2 = class {
12655
12574
  method,
12656
12575
  params
12657
12576
  };
12658
- const pending = getPendingRequests();
12659
12577
  return new Promise((resolve, reject) => {
12660
12578
  const timer = setTimeout(() => {
12661
- pending.delete(id);
12579
+ this.pendingRequests.delete(id);
12662
12580
  reject(new Error(`Gateway method "${method}" timed out after ${timeoutMs}ms`));
12663
12581
  }, timeoutMs);
12664
- pending.set(id, {
12582
+ this.pendingRequests.set(id, {
12665
12583
  resolve,
12666
12584
  reject,
12667
12585
  timer
@@ -12680,12 +12598,11 @@ var GatewayClient2 = class {
12680
12598
  clearTimeout(this.reconnectTimer);
12681
12599
  this.reconnectTimer = null;
12682
12600
  }
12683
- const pending = getPendingRequests();
12684
- for (const [, entry] of pending) {
12601
+ for (const [, entry] of this.pendingRequests) {
12685
12602
  clearTimeout(entry.timer);
12686
12603
  entry.reject(new Error("Gateway client closed"));
12687
12604
  }
12688
- pending.clear();
12605
+ this.pendingRequests.clear();
12689
12606
  if (this.ws) {
12690
12607
  this.ws.close();
12691
12608
  this.ws = null;
@@ -12715,10 +12632,9 @@ var GatewayClient2 = class {
12715
12632
  }
12716
12633
  }
12717
12634
  handleResponse(frame) {
12718
- const pending = getPendingRequests();
12719
- const entry = pending.get(frame.id);
12635
+ const entry = this.pendingRequests.get(frame.id);
12720
12636
  if (!entry) return;
12721
- pending.delete(frame.id);
12637
+ this.pendingRequests.delete(frame.id);
12722
12638
  clearTimeout(entry.timer);
12723
12639
  if (frame.ok) {
12724
12640
  entry.resolve(frame.payload);
@@ -12793,6 +12709,7 @@ var GatewayClient2 = class {
12793
12709
  diag("GW_CLIENT_RECONNECTING", { attempt: this.reconnectAttempts });
12794
12710
  await this.connect();
12795
12711
  diag("GW_CLIENT_RECONNECTED", { attempt: this.reconnectAttempts });
12712
+ this.onReconnect?.();
12796
12713
  } catch (err) {
12797
12714
  diag("GW_CLIENT_RECONNECT_FAILED", {
12798
12715
  attempt: this.reconnectAttempts,
@@ -12803,6 +12720,94 @@ var GatewayClient2 = class {
12803
12720
  }
12804
12721
  };
12805
12722
 
12723
+ // src/micro-batch.ts
12724
+ var MicroBatch = class {
12725
+ buffer = [];
12726
+ timer = null;
12727
+ destroyed = false;
12728
+ maxSize;
12729
+ maxDelayMs;
12730
+ flushFn;
12731
+ onError;
12732
+ constructor(options) {
12733
+ this.maxSize = options.maxSize;
12734
+ this.maxDelayMs = options.maxDelayMs;
12735
+ this.flushFn = options.flush;
12736
+ this.onError = options.onError ?? ((err) => console.error("MicroBatch flush error:", err));
12737
+ }
12738
+ /**
12739
+ * Add an item to the batch.
12740
+ *
12741
+ * If the buffer was empty before this add, schedules an immediate flush
12742
+ * on the next tick (setTimeout 0). If the buffer already had items (burst),
12743
+ * starts a coalescing timer that fires after maxDelayMs.
12744
+ *
12745
+ * If the buffer reaches maxSize, flushes immediately.
12746
+ */
12747
+ add(item) {
12748
+ if (this.destroyed) return;
12749
+ const wasEmpty = this.buffer.length === 0;
12750
+ this.buffer.push(item);
12751
+ if (this.buffer.length >= this.maxSize) {
12752
+ this.clearTimer();
12753
+ this.doFlush();
12754
+ return;
12755
+ }
12756
+ if (wasEmpty) {
12757
+ this.clearTimer();
12758
+ this.timer = setTimeout(() => {
12759
+ this.timer = null;
12760
+ this.doFlush();
12761
+ }, 0);
12762
+ } else if (!this.timer) {
12763
+ this.timer = setTimeout(() => {
12764
+ this.timer = null;
12765
+ this.doFlush();
12766
+ }, this.maxDelayMs);
12767
+ }
12768
+ }
12769
+ /**
12770
+ * Flush all remaining items. Used for graceful shutdown.
12771
+ * No-op when buffer is empty.
12772
+ */
12773
+ drain() {
12774
+ this.clearTimer();
12775
+ if (this.buffer.length > 0) {
12776
+ this.doFlush();
12777
+ }
12778
+ }
12779
+ /**
12780
+ * Clear any pending timer and discard buffered items.
12781
+ * The batch will not accept new items after destroy.
12782
+ */
12783
+ destroy() {
12784
+ this.destroyed = true;
12785
+ this.clearTimer();
12786
+ this.buffer = [];
12787
+ }
12788
+ doFlush() {
12789
+ if (this.buffer.length === 0) return;
12790
+ const items = this.buffer;
12791
+ this.buffer = [];
12792
+ try {
12793
+ const result = this.flushFn(items);
12794
+ if (result && typeof result.catch === "function") {
12795
+ result.catch((err) => {
12796
+ this.onError(err);
12797
+ });
12798
+ }
12799
+ } catch (err) {
12800
+ this.onError(err);
12801
+ }
12802
+ }
12803
+ clearTimer() {
12804
+ if (this.timer !== null) {
12805
+ clearTimeout(this.timer);
12806
+ this.timer = null;
12807
+ }
12808
+ }
12809
+ };
12810
+
12806
12811
  // src/agent-state.ts
12807
12812
  import { basename } from "node:path";
12808
12813
 
@@ -13051,6 +13056,16 @@ function buildActivityEntry(agentName, hook, context) {
13051
13056
  return null;
13052
13057
  }
13053
13058
  }
13059
+ var channelAgentBridge = /* @__PURE__ */ new Map();
13060
+ function setChannelAgent(channelId, agentName) {
13061
+ channelAgentBridge.set(channelId, agentName);
13062
+ }
13063
+ function getChannelAgent(channelId) {
13064
+ return channelAgentBridge.get(channelId) ?? null;
13065
+ }
13066
+ function getChannelAgentBridge() {
13067
+ return channelAgentBridge;
13068
+ }
13054
13069
  var AgentStateTracker = class {
13055
13070
  agents = /* @__PURE__ */ new Map();
13056
13071
  activityBuffer = [];
@@ -13301,11 +13316,26 @@ var POCKET_GUIDE = `# Cohort Agent Guide (Pocket Version)
13301
13316
  `;
13302
13317
 
13303
13318
  // src/hooks.ts
13304
- var BUILD_ID = "B9-ACCOUNTID-20260311";
13319
+ var REDACT_KEYS = /* @__PURE__ */ new Set([
13320
+ "token",
13321
+ "apikey",
13322
+ "secret",
13323
+ "password",
13324
+ "credential",
13325
+ "authorization",
13326
+ "accesstoken",
13327
+ "refreshtoken",
13328
+ "bearer",
13329
+ "privatekey"
13330
+ ]);
13305
13331
  function dumpCtx(ctx) {
13306
13332
  if (!ctx || typeof ctx !== "object") return { _raw: String(ctx) };
13307
13333
  const out = {};
13308
13334
  for (const key of Object.keys(ctx)) {
13335
+ if (REDACT_KEYS.has(key.toLowerCase())) {
13336
+ out[key] = "[REDACTED]";
13337
+ continue;
13338
+ }
13309
13339
  const val = ctx[key];
13310
13340
  if (typeof val === "function") {
13311
13341
  out[key] = "[Function]";
@@ -13331,8 +13361,7 @@ try {
13331
13361
  PLUGIN_VERSION = pkgJson.version ?? "unknown";
13332
13362
  } catch {
13333
13363
  }
13334
- diag("MODULE_LOADED", { BUILD_ID, PLUGIN_VERSION });
13335
- var lastCronSnapshotJson = "";
13364
+ diag("MODULE_LOADED", { PLUGIN_VERSION });
13336
13365
  function resolveGatewayToken(api) {
13337
13366
  const rawToken = api.config?.gateway?.auth?.token;
13338
13367
  if (typeof rawToken === "string") return rawToken;
@@ -13341,26 +13370,6 @@ function resolveGatewayToken(api) {
13341
13370
  }
13342
13371
  return null;
13343
13372
  }
13344
- async function quickCronSync(port, token, cfg, resolveAgentName, logger) {
13345
- const client2 = new GatewayClient2(port, token, logger, PLUGIN_VERSION);
13346
- try {
13347
- await client2.connect();
13348
- const result = await client2.request("cron.list", { includeDisabled: true });
13349
- 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 });
13350
- const jobs = Array.isArray(result) ? result : result?.jobs ?? [];
13351
- const mapped = jobs.map((j) => mapCronJob(j, resolveAgentName));
13352
- const serialized = JSON.stringify(mapped);
13353
- if (serialized !== lastCronSnapshotJson) {
13354
- await pushCronSnapshot(cfg.apiKey, mapped);
13355
- lastCronSnapshotJson = serialized;
13356
- diag("HEARTBEAT_CRON_PUSHED", { count: mapped.length });
13357
- } else {
13358
- diag("HEARTBEAT_CRON_UNCHANGED", {});
13359
- }
13360
- } finally {
13361
- client2.close();
13362
- }
13363
- }
13364
13373
  function registerCronEventHandlers(client2, cfg, resolveAgentName) {
13365
13374
  if (client2.availableEvents.has("cron")) {
13366
13375
  let debounceTimer = null;
@@ -13412,26 +13421,25 @@ function resolveIdentity(configIdentity, workspaceDir) {
13412
13421
  avatar: configIdentity?.avatar ?? fileIdentity?.avatar
13413
13422
  };
13414
13423
  }
13424
+ var tracker = null;
13415
13425
  function getOrCreateTracker() {
13416
- const state = getHotState();
13417
- if (state.tracker instanceof AgentStateTracker) {
13418
- return state.tracker;
13419
- }
13420
- const fresh = new AgentStateTracker();
13421
- state.tracker = fresh;
13422
- return fresh;
13423
- }
13424
- var STATE_FILE_PATH = path3.join(
13425
- path3.dirname(new URL(import.meta.url).pathname),
13426
- ".session-state.json"
13427
- );
13428
- function saveSessionsToDisk(tracker) {
13426
+ if (!tracker) tracker = new AgentStateTracker();
13427
+ return tracker;
13428
+ }
13429
+ var gatewayPort = null;
13430
+ var gatewayToken = null;
13431
+ var persistentGwClient = null;
13432
+ var gwClientInitialized = false;
13433
+ var keepaliveInterval = null;
13434
+ var commandUnsubscriber2 = null;
13435
+ var STATE_FILE_PATH = "";
13436
+ function saveSessionsToDisk(tracker2) {
13429
13437
  try {
13430
- const state = tracker.exportState();
13438
+ const state = tracker2.exportState();
13431
13439
  const data = {
13432
13440
  sessions: [],
13433
13441
  sessionKeyToAgent: Object.fromEntries(state.sessionKeyToAgent),
13434
- channelAgents: { ...globalThis["__cohort_sync_channel_agent__"] ?? {} },
13442
+ channelAgents: Object.fromEntries(getChannelAgentBridge()),
13435
13443
  savedAt: (/* @__PURE__ */ new Date()).toISOString()
13436
13444
  };
13437
13445
  for (const [name, agent] of state.agents) {
@@ -13443,7 +13451,7 @@ function saveSessionsToDisk(tracker) {
13443
13451
  } catch {
13444
13452
  }
13445
13453
  }
13446
- function loadSessionsFromDisk(tracker, logger) {
13454
+ function loadSessionsFromDisk(tracker2, logger) {
13447
13455
  try {
13448
13456
  if (!fs3.existsSync(STATE_FILE_PATH)) return;
13449
13457
  const data = JSON.parse(fs3.readFileSync(STATE_FILE_PATH, "utf8"));
@@ -13453,13 +13461,13 @@ function loadSessionsFromDisk(tracker, logger) {
13453
13461
  }
13454
13462
  let count = 0;
13455
13463
  for (const { agentName, key } of data.sessions) {
13456
- if (!tracker.hasSession(agentName, key)) {
13457
- tracker.addSession(agentName, key);
13464
+ if (!tracker2.hasSession(agentName, key)) {
13465
+ tracker2.addSession(agentName, key);
13458
13466
  count++;
13459
13467
  }
13460
13468
  }
13461
13469
  for (const [key, agent] of Object.entries(data.sessionKeyToAgent)) {
13462
- tracker.setSessionAgent(key, agent);
13470
+ tracker2.setSessionAgent(key, agent);
13463
13471
  }
13464
13472
  for (const [channelId, agent] of Object.entries(data.channelAgents ?? {})) {
13465
13473
  setChannelAgent(channelId, agent);
@@ -13487,15 +13495,48 @@ async function fetchAgentContext(apiKey, apiUrl, logger) {
13487
13495
  return POCKET_GUIDE;
13488
13496
  }
13489
13497
  }
13498
+ async function initGatewayClient(port, token, cfg, resolveAgentName, logger) {
13499
+ const client2 = new GatewayClient(port, token, logger, PLUGIN_VERSION);
13500
+ await client2.connect();
13501
+ persistentGwClient = client2;
13502
+ gwClientInitialized = true;
13503
+ registerCronEventHandlers(client2, cfg, resolveAgentName);
13504
+ if (client2.availableEvents.has("shutdown")) {
13505
+ client2.on("shutdown", () => {
13506
+ diag("GW_CLIENT_SHUTDOWN_EVENT", {});
13507
+ logger.info("cohort-sync: gateway shutdown event received");
13508
+ });
13509
+ }
13510
+ client2.onReconnect = async () => {
13511
+ diag("GW_CLIENT_RECONNECTED_RESUBSCRIBE", {});
13512
+ registerCronEventHandlers(client2, cfg, resolveAgentName);
13513
+ try {
13514
+ const cronResult2 = await client2.request("cron.list", { includeDisabled: true });
13515
+ const jobs2 = Array.isArray(cronResult2) ? cronResult2 : cronResult2?.jobs ?? [];
13516
+ const mapped2 = jobs2.map((j) => mapCronJob(j, resolveAgentName));
13517
+ await pushCronSnapshot(cfg.apiKey, mapped2);
13518
+ diag("GW_CLIENT_RECONNECT_CRON_PUSH", { count: mapped2.length });
13519
+ } catch (err) {
13520
+ diag("GW_CLIENT_RECONNECT_CRON_FAILED", { error: String(err) });
13521
+ }
13522
+ };
13523
+ diag("GW_CLIENT_CONNECTED", { methods: client2.availableMethods.size, events: client2.availableEvents.size });
13524
+ const cronResult = await client2.request("cron.list", { includeDisabled: true });
13525
+ const jobs = Array.isArray(cronResult) ? cronResult : cronResult?.jobs ?? [];
13526
+ const mapped = jobs.map((j) => mapCronJob(j, resolveAgentName));
13527
+ await pushCronSnapshot(cfg.apiKey, mapped);
13528
+ diag("GW_CLIENT_INITIAL_CRON_PUSH", { count: mapped.length });
13529
+ }
13490
13530
  function registerHooks(api, cfg) {
13531
+ STATE_FILE_PATH = path3.join(cfg.stateDir, "session-state.json");
13491
13532
  const { logger, config } = api;
13492
13533
  const nameMap = cfg.agentNameMap;
13493
- const tracker = getOrCreateTracker();
13494
- let heartbeatInterval = null;
13495
- let activityFlushInterval = null;
13496
- logger.info(`cohort-sync: registerHooks [${BUILD_ID}]`);
13534
+ const tracker2 = getOrCreateTracker();
13535
+ const convexUrl = cfg.convexUrl ?? deriveConvexUrl(cfg.apiUrl);
13536
+ createClient(convexUrl);
13537
+ setLogger(logger);
13538
+ logger.info(`cohort-sync: registerHooks v${PLUGIN_VERSION}`);
13497
13539
  diag("REGISTER_HOOKS", {
13498
- BUILD_ID,
13499
13540
  PLUGIN_VERSION,
13500
13541
  hasNameMap: !!nameMap,
13501
13542
  nameMapKeys: nameMap ? Object.keys(nameMap) : [],
@@ -13504,10 +13545,6 @@ function registerHooks(api, cfg) {
13504
13545
  agentIds: (config?.agents?.list ?? []).map((a) => a.id),
13505
13546
  agentMessageProviders: (config?.agents?.list ?? []).map((a) => ({ id: a.id, mp: a.messageProvider }))
13506
13547
  });
13507
- setConvexUrl(cfg);
13508
- setLogger(logger);
13509
- restoreFromHotReload(logger);
13510
- restoreRosterFromHotReload(getRosterHotState(), logger);
13511
13548
  const identityNameMap = {};
13512
13549
  const mainIdentity = parseIdentityFile(process.cwd());
13513
13550
  if (mainIdentity?.name) {
@@ -13520,14 +13557,14 @@ function registerHooks(api, cfg) {
13520
13557
  }
13521
13558
  }
13522
13559
  diag("IDENTITY_NAME_MAP", { identityNameMap });
13523
- if (tracker.getAgentNames().length === 0) {
13524
- loadSessionsFromDisk(tracker, logger);
13525
- const restoredAgents = tracker.getAgentNames();
13560
+ if (tracker2.getAgentNames().length === 0) {
13561
+ loadSessionsFromDisk(tracker2, logger);
13562
+ const restoredAgents = tracker2.getAgentNames();
13526
13563
  for (const agentName of restoredAgents) {
13527
- const sessSnapshot = tracker.getSessionsSnapshot(agentName);
13564
+ const sessSnapshot = tracker2.getSessionsSnapshot(agentName);
13528
13565
  if (sessSnapshot.length > 0) {
13529
13566
  pushSessions(cfg.apiKey, agentName, sessSnapshot).then(() => {
13530
- tracker.markSessionsPushed(agentName);
13567
+ tracker2.markSessionsPushed(agentName);
13531
13568
  logger.info(`cohort-sync: pushed ${sessSnapshot.length} restored sessions for ${agentName}`);
13532
13569
  }).catch((err) => {
13533
13570
  logger.warn(`cohort-sync: failed to push restored sessions for ${agentName}: ${String(err)}`);
@@ -13558,7 +13595,7 @@ function registerHooks(api, cfg) {
13558
13595
  }
13559
13596
  const sessionKey = ctx.sessionKey ?? ctx.sessionId;
13560
13597
  if (sessionKey && typeof sessionKey === "string") {
13561
- const mapped = tracker.getSessionAgent(sessionKey);
13598
+ const mapped = tracker2.getSessionAgent(sessionKey);
13562
13599
  if (mapped) {
13563
13600
  diag("RESOLVE_AGENT_FROM_CTX_RESULT", { method: "sessionKey_mapped", sessionKey, mapped });
13564
13601
  return mapped;
@@ -13580,10 +13617,10 @@ function registerHooks(api, cfg) {
13580
13617
  if (channelId && typeof channelId === "string") {
13581
13618
  const channelAgent = getChannelAgent(channelId);
13582
13619
  if (channelAgent) {
13583
- diag("RESOLVE_AGENT_FROM_CTX_RESULT", { method: "channelId_bridge", channelId, channelAgent, bridgeState: { ...getHotState().channelAgentBridge } });
13620
+ diag("RESOLVE_AGENT_FROM_CTX_RESULT", { method: "channelId_bridge", channelId, channelAgent, bridgeState: Object.fromEntries(getChannelAgentBridge()) });
13584
13621
  return channelAgent;
13585
13622
  }
13586
- diag("RESOLVE_AGENT_FROM_CTX_RESULT", { method: "channelId_raw", channelId, bridgeState: { ...getHotState().channelAgentBridge } });
13623
+ diag("RESOLVE_AGENT_FROM_CTX_RESULT", { method: "channelId_raw", channelId, bridgeState: Object.fromEntries(getChannelAgentBridge()) });
13587
13624
  return String(channelId);
13588
13625
  }
13589
13626
  const resolved = resolveAgentName("main");
@@ -13604,47 +13641,20 @@ function registerHooks(api, cfg) {
13604
13641
  }
13605
13642
  return "unknown";
13606
13643
  }
13607
- const pendingActivity = drainActivityFromHot();
13608
- if (pendingActivity.length > 0) {
13609
- pushActivity(cfg.apiKey, pendingActivity).catch((err) => {
13610
- logger.warn(`cohort-sync: pre-reload activity flush failed: ${String(err)}`);
13611
- });
13612
- logger.info(`cohort-sync: flushed ${pendingActivity.length} pending activity entries before hot-reload`);
13613
- }
13614
- clearIntervalsFromHot();
13615
- heartbeatInterval = setInterval(() => {
13616
- pushHeartbeat().catch((err) => {
13617
- logger.warn(`cohort-sync: heartbeat tick failed: ${String(err)}`);
13618
- });
13619
- }, 12e4);
13620
- activityFlushInterval = setInterval(() => {
13621
- flushActivityBuffer().catch((err) => {
13622
- logger.warn(`cohort-sync: activity flush tick failed: ${String(err)}`);
13623
- });
13624
- }, 3e3);
13625
- saveIntervalsToHot(heartbeatInterval, activityFlushInterval);
13626
- logger.info("cohort-sync: intervals created (heartbeat=2m, activityFlush=3s)");
13627
- {
13628
- const hotState = getHotState();
13629
- if (hotState.commandSubscription) {
13630
- hotState.commandSubscription();
13631
- hotState.commandSubscription = null;
13632
- }
13633
- const unsub = initCommandSubscription(cfg, logger, resolveAgentName);
13634
- hotState.commandSubscription = unsub;
13635
- }
13644
+ const activityBatch = new MicroBatch({
13645
+ maxSize: 10,
13646
+ maxDelayMs: 1e3,
13647
+ flush: (entries) => pushActivity(cfg.apiKey, entries),
13648
+ onError: (err) => logger.warn(`cohort-sync: activity batch flush failed: ${String(err)}`)
13649
+ });
13650
+ const KEEPALIVE_INTERVAL_MS = 15e4;
13636
13651
  {
13637
- const port = api.config?.gateway?.port;
13638
- const token = resolveGatewayToken(api);
13639
- if (port && token) {
13640
- const hotState = getHotState();
13641
- hotState.gatewayPort = port;
13642
- hotState.gatewayToken = token;
13643
- diag("REGISTER_HOOKS_CRON_SYNC", { port });
13644
- quickCronSync(port, token, cfg, resolveAgentName, logger).catch((err) => {
13645
- diag("REGISTER_HOOKS_CRON_SYNC_FAILED", { error: String(err) });
13646
- });
13652
+ if (commandUnsubscriber2) {
13653
+ commandUnsubscriber2();
13654
+ commandUnsubscriber2 = null;
13647
13655
  }
13656
+ const unsub = startCommandSubscription(cfg, logger, resolveAgentName, persistentGwClient);
13657
+ commandUnsubscriber2 = unsub;
13648
13658
  }
13649
13659
  api.registerTool((toolCtx) => {
13650
13660
  const agentId = toolCtx.agentId ?? "main";
@@ -13760,52 +13770,6 @@ Do not attempt to make more comments until ${resetAt}.`
13760
13770
  if (m.includes("deepseek")) return 128e3;
13761
13771
  return 2e5;
13762
13772
  }
13763
- async function pushHeartbeat() {
13764
- const allAgentIds = ["main", ...(config?.agents?.list ?? []).map((a) => a.id)];
13765
- for (const agentId of allAgentIds) {
13766
- const agentName = resolveAgentName(agentId);
13767
- const pruned = tracker.pruneStaleSessions(agentName, 864e5);
13768
- if (pruned.length > 0) {
13769
- logger.info(`cohort-sync: pruned ${pruned.length} stale sessions for ${agentName}`);
13770
- }
13771
- }
13772
- for (const agentId of allAgentIds) {
13773
- const agentName = resolveAgentName(agentId);
13774
- try {
13775
- const snapshot = tracker.getTelemetrySnapshot(agentName);
13776
- if (snapshot) {
13777
- await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
13778
- } else {
13779
- logger.info(`cohort-sync: heartbeat skipped ${agentName} \u2014 no snapshot in tracker`);
13780
- }
13781
- } catch (err) {
13782
- logger.warn(`cohort-sync: heartbeat push failed for ${agentName}: ${String(err)}`);
13783
- }
13784
- }
13785
- for (const agentId of allAgentIds) {
13786
- const agentName = resolveAgentName(agentId);
13787
- try {
13788
- if (tracker.shouldPushSessions(agentName)) {
13789
- const sessSnapshot = tracker.getSessionsSnapshot(agentName);
13790
- await pushSessions(cfg.apiKey, agentName, sessSnapshot);
13791
- tracker.markSessionsPushed(agentName);
13792
- }
13793
- } catch (err) {
13794
- logger.warn(`cohort-sync: heartbeat session push failed for ${agentName}: ${String(err)}`);
13795
- }
13796
- }
13797
- saveSessionsToDisk(tracker);
13798
- logger.info(`cohort-sync: heartbeat pushed for ${allAgentIds.length} agents`);
13799
- }
13800
- async function flushActivityBuffer() {
13801
- const entries = drainActivityFromHot();
13802
- if (entries.length === 0) return;
13803
- try {
13804
- await pushActivity(cfg.apiKey, entries);
13805
- } catch (err) {
13806
- logger.warn(`cohort-sync: activity flush failed: ${String(err)}`);
13807
- }
13808
- }
13809
13773
  api.on("gateway_start", async (event) => {
13810
13774
  diag("HOOK_gateway_start", { port: event.port, eventKeys: Object.keys(event) });
13811
13775
  try {
@@ -13831,24 +13795,20 @@ Do not attempt to make more comments until ${resetAt}.`
13831
13795
  setChannelAgent(mp, agentName);
13832
13796
  }
13833
13797
  }
13834
- diag("GATEWAY_START_BRIDGE_AFTER_SEED", { bridge: { ...getHotState().channelAgentBridge } });
13835
- const hotState = getHotState();
13836
- hotState.gatewayPort = event.port;
13798
+ diag("GATEWAY_START_BRIDGE_AFTER_SEED", { bridge: Object.fromEntries(getChannelAgentBridge()) });
13799
+ gatewayPort = event.port;
13837
13800
  const token = resolveGatewayToken(api);
13838
13801
  if (token) {
13802
+ gatewayToken = token;
13839
13803
  diag("GW_CLIENT_CONNECTING", { port: event.port, hasToken: true });
13840
13804
  try {
13841
- const client2 = new GatewayClient2(event.port, token, logger, PLUGIN_VERSION);
13842
- await client2.connect();
13843
- hotState.gatewayProtocolClient = client2;
13844
- registerCronEventHandlers(client2, cfg, resolveAgentName);
13845
- diag("GW_CLIENT_CONNECTED", { methods: client2.availableMethods.size, events: client2.availableEvents.size });
13846
- const cronResult = await client2.request("cron.list", { includeDisabled: true });
13847
- const jobs = Array.isArray(cronResult) ? cronResult : cronResult?.jobs ?? [];
13848
- const mapped = jobs.map((j) => mapCronJob(j, resolveAgentName));
13849
- await pushCronSnapshot(cfg.apiKey, mapped);
13850
- lastCronSnapshotJson = JSON.stringify(mapped);
13851
- diag("GW_CLIENT_INITIAL_CRON_PUSH", { count: mapped.length });
13805
+ await initGatewayClient(event.port, token, cfg, resolveAgentName, logger);
13806
+ if (commandUnsubscriber2) {
13807
+ commandUnsubscriber2();
13808
+ commandUnsubscriber2 = null;
13809
+ }
13810
+ const unsub = startCommandSubscription(cfg, logger, resolveAgentName, persistentGwClient);
13811
+ commandUnsubscriber2 = unsub;
13852
13812
  } catch (err) {
13853
13813
  diag("GW_CLIENT_CONNECT_FAILED", { error: String(err) });
13854
13814
  logger.error(`cohort-sync: gateway client connect failed: ${String(err)}`);
@@ -13857,7 +13817,7 @@ Do not attempt to make more comments until ${resetAt}.`
13857
13817
  diag("GW_CLIENT_NO_TOKEN", {});
13858
13818
  logger.warn("cohort-sync: no gateway auth token \u2014 cron operations disabled");
13859
13819
  }
13860
- await initSubscription(
13820
+ await startNotificationSubscription(
13861
13821
  event.port,
13862
13822
  cfg,
13863
13823
  api.config.hooks?.token,
@@ -13869,31 +13829,48 @@ Do not attempt to make more comments until ${resetAt}.`
13869
13829
  for (const agentId of allAgentIds) {
13870
13830
  const agentName = resolveAgentName(agentId);
13871
13831
  try {
13872
- tracker.setModel(agentName, resolveModel(agentId));
13873
- tracker.updateStatus(agentName, "idle");
13874
- const snapshot = tracker.getTelemetrySnapshot(agentName);
13832
+ tracker2.setModel(agentName, resolveModel(agentId));
13833
+ tracker2.updateStatus(agentName, "idle");
13834
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
13875
13835
  if (snapshot) {
13876
13836
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
13877
- tracker.markTelemetryPushed(agentName);
13837
+ tracker2.markTelemetryPushed(agentName);
13878
13838
  }
13879
13839
  } catch (err) {
13880
13840
  logger.warn(`cohort-sync: initial telemetry seed failed for ${agentName}: ${String(err)}`);
13881
13841
  }
13882
13842
  }
13883
13843
  logger.info(`cohort-sync: seeded telemetry for ${allAgentIds.length} agents`);
13844
+ if (keepaliveInterval) clearInterval(keepaliveInterval);
13845
+ keepaliveInterval = setInterval(async () => {
13846
+ for (const agentId of allAgentIds) {
13847
+ const agentName = resolveAgentName(agentId);
13848
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
13849
+ if (snapshot) {
13850
+ await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION }).catch(() => {
13851
+ });
13852
+ }
13853
+ }
13854
+ for (const agentId of allAgentIds) {
13855
+ const agentName = resolveAgentName(agentId);
13856
+ tracker2.pruneStaleSessions(agentName, 864e5);
13857
+ }
13858
+ saveSessionsToDisk(tracker2);
13859
+ }, KEEPALIVE_INTERVAL_MS);
13860
+ logger.info(`cohort-sync: keepalive interval started (${KEEPALIVE_INTERVAL_MS / 1e3}s)`);
13884
13861
  });
13885
13862
  api.on("agent_end", async (event, ctx) => {
13886
13863
  diag("HOOK_agent_end", { ctx: dumpCtx(ctx), success: event.success, error: event.error, durationMs: event.durationMs });
13887
13864
  const agentId = ctx.agentId ?? "main";
13888
13865
  const agentName = resolveAgentName(agentId);
13889
13866
  try {
13890
- tracker.updateStatus(agentName, "idle");
13867
+ tracker2.updateStatus(agentName, "idle");
13891
13868
  await syncAgentStatus(agentName, "idle", resolveModel(agentId), cfg, logger);
13892
- if (tracker.shouldPushTelemetry(agentName)) {
13893
- const snapshot = tracker.getTelemetrySnapshot(agentName);
13869
+ if (tracker2.shouldPushTelemetry(agentName)) {
13870
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
13894
13871
  if (snapshot) {
13895
13872
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
13896
- tracker.markTelemetryPushed(agentName);
13873
+ tracker2.markTelemetryPushed(agentName);
13897
13874
  }
13898
13875
  }
13899
13876
  if (event.success === false) {
@@ -13903,7 +13880,7 @@ Do not attempt to make more comments until ${resetAt}.`
13903
13880
  durationMs: event.durationMs,
13904
13881
  sessionKey: ctx.sessionKey
13905
13882
  });
13906
- if (entry) addActivityToHot(entry);
13883
+ if (entry) activityBatch.add(entry);
13907
13884
  }
13908
13885
  } catch (err) {
13909
13886
  logger.warn(`cohort-sync: agent_end sync failed: ${String(err)}`);
@@ -13928,31 +13905,31 @@ Do not attempt to make more comments until ${resetAt}.`
13928
13905
  const agentName = resolveAgentName(agentId);
13929
13906
  try {
13930
13907
  const sessionKey = ctx.sessionKey;
13931
- tracker.updateFromLlmOutput(agentName, sessionKey, {
13908
+ tracker2.updateFromLlmOutput(agentName, sessionKey, {
13932
13909
  model,
13933
13910
  tokensIn: usage.input ?? 0,
13934
13911
  tokensOut: usage.output ?? 0,
13935
13912
  contextTokens,
13936
13913
  contextLimit
13937
13914
  });
13938
- if (sessionKey && !tracker.hasSession(agentName, sessionKey)) {
13939
- tracker.addSession(agentName, sessionKey);
13915
+ if (sessionKey && !tracker2.hasSession(agentName, sessionKey)) {
13916
+ tracker2.addSession(agentName, sessionKey);
13940
13917
  logger.info(`cohort-sync: inferred session for ${agentName} from llm_output (${sessionKey})`);
13941
13918
  }
13942
13919
  if (sessionKey) {
13943
- tracker.setSessionAgent(sessionKey, agentName);
13920
+ tracker2.setSessionAgent(sessionKey, agentName);
13944
13921
  }
13945
- if (tracker.shouldPushTelemetry(agentName)) {
13946
- const snapshot = tracker.getTelemetrySnapshot(agentName);
13922
+ if (tracker2.shouldPushTelemetry(agentName)) {
13923
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
13947
13924
  if (snapshot) {
13948
13925
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
13949
- tracker.markTelemetryPushed(agentName);
13926
+ tracker2.markTelemetryPushed(agentName);
13950
13927
  }
13951
13928
  }
13952
- if (tracker.shouldPushSessions(agentName)) {
13953
- const sessionsSnapshot = tracker.getSessionsSnapshot(agentName);
13929
+ if (tracker2.shouldPushSessions(agentName)) {
13930
+ const sessionsSnapshot = tracker2.getSessionsSnapshot(agentName);
13954
13931
  await pushSessions(cfg.apiKey, agentName, sessionsSnapshot);
13955
- tracker.markSessionsPushed(agentName);
13932
+ tracker2.markSessionsPushed(agentName);
13956
13933
  }
13957
13934
  } catch (err) {
13958
13935
  logger.warn(`cohort-sync: llm_output telemetry failed: ${String(err)}`);
@@ -13963,15 +13940,15 @@ Do not attempt to make more comments until ${resetAt}.`
13963
13940
  const agentId = ctx.agentId ?? "main";
13964
13941
  const agentName = resolveAgentName(agentId);
13965
13942
  try {
13966
- tracker.updateFromCompaction(agentName, {
13943
+ tracker2.updateFromCompaction(agentName, {
13967
13944
  contextTokens: event.tokenCount ?? 0,
13968
13945
  contextLimit: getModelContextLimit(resolveModel(agentId))
13969
13946
  });
13970
- if (tracker.shouldPushTelemetry(agentName)) {
13971
- const snapshot = tracker.getTelemetrySnapshot(agentName);
13947
+ if (tracker2.shouldPushTelemetry(agentName)) {
13948
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
13972
13949
  if (snapshot) {
13973
13950
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
13974
- tracker.markTelemetryPushed(agentName);
13951
+ tracker2.markTelemetryPushed(agentName);
13975
13952
  }
13976
13953
  }
13977
13954
  const entry = buildActivityEntry(agentName, "after_compaction", {
@@ -13979,7 +13956,7 @@ Do not attempt to make more comments until ${resetAt}.`
13979
13956
  compactedCount: event.compactedCount,
13980
13957
  sessionKey: ctx.sessionKey
13981
13958
  });
13982
- if (entry) addActivityToHot(entry);
13959
+ if (entry) activityBatch.add(entry);
13983
13960
  } catch (err) {
13984
13961
  logger.warn(`cohort-sync: after_compaction telemetry failed: ${String(err)}`);
13985
13962
  }
@@ -13990,26 +13967,39 @@ Do not attempt to make more comments until ${resetAt}.`
13990
13967
  const agentName = resolveAgentName(agentId);
13991
13968
  diag("HOOK_before_agent_start_RESOLVED", { agentId, agentName, ctxChannelId: ctx.channelId, ctxMessageProvider: ctx.messageProvider, ctxSessionKey: ctx.sessionKey, ctxAccountId: ctx.accountId });
13992
13969
  try {
13993
- tracker.updateStatus(agentName, "working");
13994
- if (tracker.shouldPushTelemetry(agentName)) {
13995
- const snapshot = tracker.getTelemetrySnapshot(agentName);
13970
+ if (!gwClientInitialized && gatewayPort && gatewayToken) {
13971
+ try {
13972
+ await initGatewayClient(gatewayPort, gatewayToken, cfg, resolveAgentName, logger);
13973
+ if (commandUnsubscriber2) {
13974
+ commandUnsubscriber2();
13975
+ commandUnsubscriber2 = null;
13976
+ }
13977
+ const unsub = startCommandSubscription(cfg, logger, resolveAgentName, persistentGwClient);
13978
+ commandUnsubscriber2 = unsub;
13979
+ } catch (err) {
13980
+ diag("GW_CLIENT_LAZY_INIT_FAILED", { error: String(err) });
13981
+ }
13982
+ }
13983
+ tracker2.updateStatus(agentName, "working");
13984
+ if (tracker2.shouldPushTelemetry(agentName)) {
13985
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
13996
13986
  if (snapshot) {
13997
13987
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
13998
- tracker.markTelemetryPushed(agentName);
13988
+ tracker2.markTelemetryPushed(agentName);
13999
13989
  }
14000
13990
  }
14001
13991
  const sessionKey = ctx.sessionKey;
14002
- if (sessionKey && !tracker.hasSession(agentName, sessionKey)) {
14003
- tracker.addSession(agentName, sessionKey);
14004
- tracker.setSessionAgent(sessionKey, agentName);
13992
+ if (sessionKey && !tracker2.hasSession(agentName, sessionKey)) {
13993
+ tracker2.addSession(agentName, sessionKey);
13994
+ tracker2.setSessionAgent(sessionKey, agentName);
14005
13995
  logger.info(`cohort-sync: inferred session for ${agentName} (${sessionKey})`);
14006
- if (tracker.shouldPushSessions(agentName)) {
14007
- const sessSnapshot = tracker.getSessionsSnapshot(agentName);
13996
+ if (tracker2.shouldPushSessions(agentName)) {
13997
+ const sessSnapshot = tracker2.getSessionsSnapshot(agentName);
14008
13998
  await pushSessions(cfg.apiKey, agentName, sessSnapshot);
14009
- tracker.markSessionsPushed(agentName);
13999
+ tracker2.markSessionsPushed(agentName);
14010
14000
  }
14011
14001
  } else if (sessionKey) {
14012
- tracker.setSessionAgent(sessionKey, agentName);
14002
+ tracker2.setSessionAgent(sessionKey, agentName);
14013
14003
  }
14014
14004
  const ctxChannelId = ctx.channelId;
14015
14005
  if (ctxChannelId) {
@@ -14029,11 +14019,11 @@ Do not attempt to make more comments until ${resetAt}.`
14029
14019
  const agentName = resolveAgentName(agentId);
14030
14020
  try {
14031
14021
  const sessionKey = ctx.sessionId ?? String(Date.now());
14032
- tracker.addSession(agentName, sessionKey);
14033
- if (tracker.shouldPushSessions(agentName)) {
14034
- const sessionsSnapshot = tracker.getSessionsSnapshot(agentName);
14022
+ tracker2.addSession(agentName, sessionKey);
14023
+ if (tracker2.shouldPushSessions(agentName)) {
14024
+ const sessionsSnapshot = tracker2.getSessionsSnapshot(agentName);
14035
14025
  await pushSessions(cfg.apiKey, agentName, sessionsSnapshot);
14036
- tracker.markSessionsPushed(agentName);
14026
+ tracker2.markSessionsPushed(agentName);
14037
14027
  }
14038
14028
  const parsed = parseSessionKey(sessionKey);
14039
14029
  const entry = buildActivityEntry(agentName, "session_start", {
@@ -14041,7 +14031,7 @@ Do not attempt to make more comments until ${resetAt}.`
14041
14031
  sessionKey,
14042
14032
  resumedFrom: event.resumedFrom
14043
14033
  });
14044
- if (entry) addActivityToHot(entry);
14034
+ if (entry) activityBatch.add(entry);
14045
14035
  } catch (err) {
14046
14036
  logger.warn(`cohort-sync: session_start tracking failed: ${String(err)}`);
14047
14037
  }
@@ -14052,18 +14042,18 @@ Do not attempt to make more comments until ${resetAt}.`
14052
14042
  const agentName = resolveAgentName(agentId);
14053
14043
  try {
14054
14044
  const sessionKey = ctx.sessionId ?? "";
14055
- tracker.removeSession(agentName, sessionKey);
14056
- if (tracker.shouldPushSessions(agentName)) {
14057
- const sessionsSnapshot = tracker.getSessionsSnapshot(agentName);
14045
+ tracker2.removeSession(agentName, sessionKey);
14046
+ if (tracker2.shouldPushSessions(agentName)) {
14047
+ const sessionsSnapshot = tracker2.getSessionsSnapshot(agentName);
14058
14048
  await pushSessions(cfg.apiKey, agentName, sessionsSnapshot);
14059
- tracker.markSessionsPushed(agentName);
14049
+ tracker2.markSessionsPushed(agentName);
14060
14050
  }
14061
14051
  const entry = buildActivityEntry(agentName, "session_end", {
14062
14052
  sessionKey,
14063
14053
  messageCount: event.messageCount,
14064
14054
  durationMs: event.durationMs
14065
14055
  });
14066
- if (entry) addActivityToHot(entry);
14056
+ if (entry) activityBatch.add(entry);
14067
14057
  } catch (err) {
14068
14058
  logger.warn(`cohort-sync: session_end tracking failed: ${String(err)}`);
14069
14059
  }
@@ -14080,7 +14070,7 @@ Do not attempt to make more comments until ${resetAt}.`
14080
14070
  sessionKey: ctx.sessionKey,
14081
14071
  model: resolveModel(ctx.agentId ?? "main")
14082
14072
  });
14083
- if (entry) addActivityToHot(entry);
14073
+ if (entry) activityBatch.add(entry);
14084
14074
  } catch (err) {
14085
14075
  logger.warn(`cohort-sync: after_tool_call activity failed: ${String(err)}`);
14086
14076
  }
@@ -14089,7 +14079,7 @@ Do not attempt to make more comments until ${resetAt}.`
14089
14079
  diag("HOOK_message_received_RAW", {
14090
14080
  ctx: dumpCtx(ctx),
14091
14081
  event: dumpEvent(_event),
14092
- bridgeStateBefore: { ...getHotState().channelAgentBridge }
14082
+ bridgeStateBefore: Object.fromEntries(getChannelAgentBridge())
14093
14083
  });
14094
14084
  const agentName = resolveAgentFromContext(ctx);
14095
14085
  const channel = ctx.channelId;
@@ -14104,7 +14094,7 @@ Do not attempt to make more comments until ${resetAt}.`
14104
14094
  const entry = buildActivityEntry(agentName, "message_received", {
14105
14095
  channel: channel ?? "unknown"
14106
14096
  });
14107
- if (entry) addActivityToHot(entry);
14097
+ if (entry) activityBatch.add(entry);
14108
14098
  } catch (err) {
14109
14099
  logger.warn(`cohort-sync: message_received activity failed: ${String(err)}`);
14110
14100
  }
@@ -14113,7 +14103,7 @@ Do not attempt to make more comments until ${resetAt}.`
14113
14103
  diag("HOOK_message_sent_RAW", {
14114
14104
  ctx: dumpCtx(ctx),
14115
14105
  event: dumpEvent(event),
14116
- bridgeStateBefore: { ...getHotState().channelAgentBridge }
14106
+ bridgeStateBefore: Object.fromEntries(getChannelAgentBridge())
14117
14107
  });
14118
14108
  const agentName = resolveAgentFromContext(ctx);
14119
14109
  const channel = ctx.channelId;
@@ -14132,7 +14122,7 @@ Do not attempt to make more comments until ${resetAt}.`
14132
14122
  success: event.success,
14133
14123
  error: event.error
14134
14124
  });
14135
- if (entry) addActivityToHot(entry);
14125
+ if (entry) activityBatch.add(entry);
14136
14126
  } catch (err) {
14137
14127
  logger.warn(`cohort-sync: message_sent activity failed: ${String(err)}`);
14138
14128
  }
@@ -14145,7 +14135,7 @@ Do not attempt to make more comments until ${resetAt}.`
14145
14135
  const entry = buildActivityEntry(agentName, "before_compaction", {
14146
14136
  sessionKey: ctx.sessionKey
14147
14137
  });
14148
- if (entry) addActivityToHot(entry);
14138
+ if (entry) activityBatch.add(entry);
14149
14139
  } catch (err) {
14150
14140
  logger.warn(`cohort-sync: before_compaction activity failed: ${String(err)}`);
14151
14141
  }
@@ -14159,25 +14149,24 @@ Do not attempt to make more comments until ${resetAt}.`
14159
14149
  reason: event.reason,
14160
14150
  sessionKey: ctx.sessionKey
14161
14151
  });
14162
- if (entry) addActivityToHot(entry);
14152
+ if (entry) activityBatch.add(entry);
14163
14153
  } catch (err) {
14164
14154
  logger.warn(`cohort-sync: before_reset activity failed: ${String(err)}`);
14165
14155
  }
14166
14156
  });
14167
14157
  api.on("gateway_stop", async () => {
14168
- diag("HOOK_gateway_stop", { bridgeState: { ...getHotState().channelAgentBridge } });
14169
- clearIntervalsFromHot();
14170
- heartbeatInterval = null;
14171
- activityFlushInterval = null;
14172
- await flushActivityBuffer().catch((err) => {
14173
- logger.warn(`cohort-sync: final activity flush failed: ${String(err)}`);
14174
- });
14158
+ diag("HOOK_gateway_stop", { bridgeState: Object.fromEntries(getChannelAgentBridge()) });
14159
+ if (keepaliveInterval) {
14160
+ clearInterval(keepaliveInterval);
14161
+ keepaliveInterval = null;
14162
+ }
14163
+ activityBatch.drain();
14175
14164
  const allAgentIds = ["main", ...(config?.agents?.list ?? []).map((a) => a.id)];
14176
14165
  for (const agentId of allAgentIds) {
14177
14166
  const agentName = resolveAgentName(agentId);
14178
14167
  try {
14179
- tracker.updateStatus(agentName, "unreachable");
14180
- const snapshot = tracker.getTelemetrySnapshot(agentName);
14168
+ tracker2.updateStatus(agentName, "unreachable");
14169
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
14181
14170
  if (snapshot) {
14182
14171
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
14183
14172
  }
@@ -14185,15 +14174,20 @@ Do not attempt to make more comments until ${resetAt}.`
14185
14174
  logger.warn(`cohort-sync: final unreachable push failed for ${agentName}: ${String(err)}`);
14186
14175
  }
14187
14176
  }
14188
- saveSessionsToDisk(tracker);
14177
+ saveSessionsToDisk(tracker2);
14178
+ if (persistentGwClient) {
14179
+ persistentGwClient.close();
14180
+ persistentGwClient = null;
14181
+ gwClientInitialized = false;
14182
+ }
14189
14183
  try {
14190
14184
  await markAllUnreachable(cfg, logger);
14191
14185
  } catch (err) {
14192
14186
  logger.warn(`cohort-sync: markAllUnreachable failed: ${String(err)}`);
14193
14187
  }
14194
- tracker.clear();
14195
- closeSubscription();
14196
- logger.info("cohort-sync: subscription closed");
14188
+ tracker2.clear();
14189
+ closeBridge();
14190
+ logger.info("cohort-sync: gateway stopped, all resources cleaned up");
14197
14191
  });
14198
14192
  }
14199
14193
 
@@ -14299,7 +14293,7 @@ function registerCohortCli(ctx, cfg) {
14299
14293
  agents.push({ id, name });
14300
14294
  }
14301
14295
  } else {
14302
- agents.push({ id: "main", name: "openclaw-cohort-sync" });
14296
+ agents.push({ id: "main", name: "main" });
14303
14297
  }
14304
14298
  const manifest = { agents };
14305
14299
  logger.info("cohort: Starting device authorization...");
@@ -14359,40 +14353,10 @@ function registerCohortCli(ctx, cfg) {
14359
14353
  // index.ts
14360
14354
  init_keychain();
14361
14355
  var DEFAULT_API_URL = "https://fortunate-chipmunk-286.convex.site";
14362
- async function doActivate(api) {
14363
- const cfg = api.pluginConfig;
14364
- const apiUrl = cfg?.apiUrl ?? DEFAULT_API_URL;
14365
- api.logger.info(`cohort-sync: activating (api: ${apiUrl})`);
14366
- let apiKey = cfg?.apiKey;
14367
- if (!apiKey) {
14368
- try {
14369
- apiKey = await getCredential(apiUrl) ?? void 0;
14370
- } catch (err) {
14371
- api.logger.error(
14372
- `cohort-sync: keychain lookup failed: ${err instanceof Error ? err.message : String(err)}`
14373
- );
14374
- }
14375
- }
14376
- if (!apiKey) {
14377
- api.logger.warn(
14378
- "cohort-sync: no API key found \u2014 run 'openclaw cohort auth' to authenticate"
14379
- );
14380
- return;
14381
- }
14382
- api.logger.info(`cohort-sync: activated (api: ${apiUrl})`);
14383
- registerHooks(api, {
14384
- apiUrl,
14385
- apiKey,
14386
- agentNameMap: cfg?.agentNameMap
14387
- });
14388
- }
14389
14356
  var plugin = {
14390
14357
  id: "cohort-sync",
14391
14358
  name: "Cohort Sync",
14392
14359
  description: "Syncs agent status and skills to Cohort dashboard",
14393
- // register() is synchronous — the SDK does not await it.
14394
- // We register CLI here, then self-activate async (the gateway does not
14395
- // call activate() for extension-directory plugins).
14396
14360
  register(api) {
14397
14361
  const cfg = api.pluginConfig;
14398
14362
  const apiUrl = cfg?.apiUrl ?? DEFAULT_API_URL;
@@ -14404,15 +14368,39 @@ var plugin = {
14404
14368
  }),
14405
14369
  { commands: ["cohort"] }
14406
14370
  );
14407
- doActivate(api).catch(
14408
- (err) => api.logger.error(
14409
- `cohort-sync: self-activation failed: ${err instanceof Error ? err.message : String(err)}`
14410
- )
14411
- );
14412
- },
14413
- // activate() kept for forward-compatibility if gateway adds activate() support.
14414
- async activate(api) {
14415
- return doActivate(api);
14371
+ api.registerService({
14372
+ id: "cohort-sync-core",
14373
+ async start(svcCtx) {
14374
+ api.logger.info(`cohort-sync: service starting (api: ${apiUrl})`);
14375
+ let apiKey = cfg?.apiKey;
14376
+ if (!apiKey) {
14377
+ try {
14378
+ apiKey = await getCredential(apiUrl) ?? void 0;
14379
+ } catch (err) {
14380
+ api.logger.error(
14381
+ `cohort-sync: keychain lookup failed: ${err instanceof Error ? err.message : String(err)}`
14382
+ );
14383
+ }
14384
+ }
14385
+ if (!apiKey) {
14386
+ api.logger.warn(
14387
+ "cohort-sync: no API key found \u2014 run 'openclaw cohort auth' to authenticate"
14388
+ );
14389
+ return;
14390
+ }
14391
+ api.logger.info(`cohort-sync: activated (api: ${apiUrl})`);
14392
+ registerHooks(api, {
14393
+ apiUrl,
14394
+ apiKey,
14395
+ stateDir: svcCtx.stateDir,
14396
+ agentNameMap: cfg?.agentNameMap
14397
+ });
14398
+ },
14399
+ async stop() {
14400
+ closeBridge();
14401
+ api.logger.info("cohort-sync: service stopped");
14402
+ }
14403
+ });
14416
14404
  }
14417
14405
  };
14418
14406
  var index_default = plugin;