@cfio/cohort-sync 0.7.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
@@ -2710,6 +2710,225 @@ var Type = type_exports2;
2710
2710
 
2711
2711
  // src/sync.ts
2712
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
+ }
2713
2932
 
2714
2933
  // ../../node_modules/.pnpm/convex@1.33.0_patch_hash=l43bztwr6e2lbmpd6ao6hmcg24_react@19.2.1/node_modules/convex/dist/esm/index.js
2715
2934
  var version = "1.33.0";
@@ -11553,130 +11772,126 @@ function reverseResolveAgentName(cohortName, forwardMap) {
11553
11772
  return cohortName;
11554
11773
  }
11555
11774
 
11556
- // src/subscription.ts
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;
11783
+ }
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;
11887
+ }
11888
+ logger.warn(`cohort-sync: unknown command type: ${cmd.type}`);
11889
+ }
11890
+
11891
+ // src/convex-bridge.ts
11557
11892
  function deriveConvexUrl(apiUrl) {
11558
11893
  return apiUrl.replace(/\.convex\.site\/?$/, ".convex.cloud");
11559
11894
  }
11560
- var DEFAULT_BEHAVIORAL_PROMPT = `BEFORE RESPONDING:
11561
- - Does your planned response address the task's stated scope? If not, do not comment.
11562
- - Do not post acknowledgment-only responses ("got it", "sounds good", "confirmed"). If you have no new information to add, do not comment.
11563
- - If the work is complete, transition the task to "waiting" and set noReply=true on your final comment, then stop working on this task.`;
11564
- function buildNotificationMessage(n) {
11565
- let header;
11566
- let cta;
11567
- switch (n.type) {
11568
- case "comment":
11569
- if (n.isMentioned) {
11570
- header = `You were @mentioned on task #${n.taskNumber} "${n.taskTitle}"
11571
- By: ${n.actorName}`;
11572
- cta = "You were directly mentioned. Read the comment and respond using the cohort_comment tool.";
11573
- } else {
11574
- header = `New comment on task #${n.taskNumber} "${n.taskTitle}"
11575
- From: ${n.actorName}`;
11576
- cta = "Read the comment thread and respond using the cohort_comment tool if appropriate.";
11577
- }
11578
- break;
11579
- case "assignment":
11580
- header = `You were assigned to task #${n.taskNumber} "${n.taskTitle}"
11581
- By: ${n.actorName}`;
11582
- cta = "Review the task description and begin working on it.";
11583
- break;
11584
- case "status_change":
11585
- header = `Status changed on task #${n.taskNumber} "${n.taskTitle}"
11586
- By: ${n.actorName}`;
11587
- cta = "Review the status change and take any follow-up action needed.";
11588
- break;
11589
- default:
11590
- header = `Notification on task #${n.taskNumber} "${n.taskTitle}"
11591
- From: ${n.actorName}`;
11592
- cta = "Check the task and respond if needed.";
11593
- }
11594
- const body = n.preview ? `
11595
- Comment: "${n.preview}"` : "";
11596
- let scope = "";
11597
- if (n.taskDescription && n.type === "comment") {
11598
- const truncated = n.taskDescription.length > 500 ? n.taskDescription.slice(0, 500) + "..." : n.taskDescription;
11599
- scope = `
11600
-
11601
- Scope: ${truncated}`;
11602
- }
11603
- const prompt = n.behavioralPrompt ? `${n.behavioralPrompt}
11604
-
11605
- ${DEFAULT_BEHAVIORAL_PROMPT}` : DEFAULT_BEHAVIORAL_PROMPT;
11606
- const promptBlock = n.type === "comment" ? `
11607
-
11608
- ---
11609
- ${prompt}` : "";
11610
- return `${header}${scope}${body}
11611
-
11612
- ${cta}${promptBlock}`;
11613
- }
11614
- async function injectNotification(port, hooksToken, n, agentId = "main") {
11615
- const response = await fetch(`http://localhost:${port}/hooks/agent`, {
11616
- method: "POST",
11617
- headers: {
11618
- "Content-Type": "application/json",
11619
- "Authorization": `Bearer ${hooksToken}`
11620
- },
11621
- body: JSON.stringify({
11622
- message: buildNotificationMessage(n),
11623
- name: "Cohort",
11624
- agentId,
11625
- deliver: false,
11626
- sessionKey: `hook:cohort:task-${n.taskNumber}`
11627
- })
11628
- });
11629
- if (!response.ok) {
11630
- throw new Error(`/hooks/agent returned ${response.status} ${response.statusText}`);
11631
- }
11632
- }
11633
- var getUndeliveredForPlugin = makeFunctionReference("notifications:getUndeliveredForPlugin");
11634
- var markDeliveredByPlugin = makeFunctionReference("notifications:markDeliveredByPlugin");
11635
- var upsertTelemetryFromPlugin = makeFunctionReference("telemetryPlugin:upsertTelemetryFromPlugin");
11636
- var upsertSessionsFromPlugin = makeFunctionReference("telemetryPlugin:upsertSessionsFromPlugin");
11637
- var pushActivityFromPluginRef = makeFunctionReference("activityFeed:pushActivityFromPlugin");
11638
- var upsertCronSnapshotFromPluginRef = makeFunctionReference("telemetryPlugin:upsertCronSnapshotFromPlugin");
11639
- var getPendingCommandsForPlugin = makeFunctionReference("gatewayCommands:getPendingForPlugin");
11640
- var acknowledgeCommandRef = makeFunctionReference("gatewayCommands:acknowledgeCommand");
11641
- var failCommandRef = makeFunctionReference("gatewayCommands:failCommand");
11642
- var addCommentFromPluginRef = makeFunctionReference("comments:addCommentFromPlugin");
11643
- var HOT_KEY = "__cohort_sync__";
11644
- function getHotState() {
11645
- let state = globalThis[HOT_KEY];
11646
- if (!state) {
11647
- state = {
11648
- tracker: null,
11649
- client: null,
11650
- convexUrl: null,
11651
- unsubscribers: [],
11652
- lastKnownRoster: [],
11653
- intervals: { heartbeat: null, activityFlush: null },
11654
- activityBuffer: [],
11655
- channelAgentBridge: {},
11656
- gatewayPort: null,
11657
- gatewayToken: null,
11658
- gatewayProtocolClient: null,
11659
- commandSubscription: null,
11660
- cronRunNowPoll: null
11661
- };
11662
- globalThis[HOT_KEY] = state;
11663
- }
11664
- if (!state.activityBuffer) state.activityBuffer = [];
11665
- if (!state.intervals) state.intervals = { heartbeat: null, activityFlush: null };
11666
- if (!state.channelAgentBridge) state.channelAgentBridge = {};
11667
- if (!state.unsubscribers) state.unsubscribers = [];
11668
- if (!state.lastKnownRoster) state.lastKnownRoster = [];
11669
- return state;
11670
- }
11671
- function clearHotState() {
11672
- delete globalThis[HOT_KEY];
11673
- }
11674
- function setRosterHotState(roster) {
11675
- getHotState().lastKnownRoster = roster;
11676
- }
11677
- function getRosterHotState() {
11678
- return getHotState().lastKnownRoster;
11679
- }
11680
11895
  var savedLogger = null;
11681
11896
  function setLogger(logger) {
11682
11897
  savedLogger = logger;
@@ -11691,288 +11906,18 @@ function getLogger() {
11691
11906
  var client = null;
11692
11907
  var savedConvexUrl = null;
11693
11908
  var unsubscribers = [];
11694
- function setConvexUrl(cfg) {
11695
- const url = cfg.convexUrl ?? deriveConvexUrl(cfg.apiUrl);
11696
- savedConvexUrl = url;
11697
- getHotState().convexUrl = url;
11698
- }
11699
- function restoreFromHotReload(logger) {
11700
- const state = getHotState();
11701
- if (!client && state.client) {
11702
- client = state.client;
11703
- savedConvexUrl = state.convexUrl;
11704
- logger.info("cohort-sync: recovered ConvexClient after hot-reload");
11705
- } else if (client && state.client && client !== state.client) {
11706
- try {
11707
- state.client.close();
11708
- } catch {
11709
- }
11710
- state.client = client;
11711
- logger.info("cohort-sync: closed orphaned ConvexClient from hot state");
11712
- }
11713
- if (unsubscribers.length === 0 && state.unsubscribers.length > 0) {
11714
- unsubscribers.push(...state.unsubscribers);
11715
- logger.info(`cohort-sync: recovered ${state.unsubscribers.length} notification subscriptions after hot-reload`);
11909
+ function createClient(convexUrl) {
11910
+ if (client) {
11911
+ client.close();
11716
11912
  }
11717
- }
11718
- function getOrCreateClient() {
11719
- if (client) return client;
11720
- const state = getHotState();
11721
- if (state.client) {
11722
- client = state.client;
11723
- getLogger().info("cohort-sync: recovered ConvexClient from globalThis");
11724
- return client;
11725
- }
11726
- if (!savedConvexUrl) return null;
11727
- client = new ConvexClient(savedConvexUrl);
11728
- getLogger().info(`cohort-sync: created fresh ConvexClient (${savedConvexUrl})`);
11729
- state.client = client;
11730
- state.convexUrl = savedConvexUrl;
11731
- return client;
11732
- }
11733
- async function initSubscription(port, cfg, hooksToken, logger) {
11734
- const convexUrl = cfg.convexUrl ?? deriveConvexUrl(cfg.apiUrl);
11735
11913
  savedConvexUrl = convexUrl;
11736
- const state = getHotState();
11737
- if (state.client) {
11738
- client = state.client;
11739
- logger.info("cohort-sync: reusing hot-reload ConvexClient for subscription");
11740
- } else {
11741
- client = new ConvexClient(convexUrl);
11742
- logger.info(`cohort-sync: created new ConvexClient for subscription (${convexUrl})`);
11743
- }
11744
- state.client = client;
11745
- state.convexUrl = convexUrl;
11746
- if (!hooksToken) {
11747
- logger.warn(
11748
- `cohort-sync: hooks.token not configured \u2014 real-time notifications disabled (telemetry will still be pushed).`
11749
- );
11750
- return;
11751
- }
11752
- const agentNames = cfg.agentNameMap ? Object.values(cfg.agentNameMap) : ["main"];
11753
- const reverseNameMap = {};
11754
- if (cfg.agentNameMap) {
11755
- for (const [openclawId, cohortName] of Object.entries(cfg.agentNameMap)) {
11756
- reverseNameMap[cohortName] = openclawId;
11757
- }
11758
- }
11759
- for (const agentName of agentNames) {
11760
- const openclawAgentId = reverseNameMap[agentName] ?? agentName;
11761
- logger.info(`cohort-sync: subscribing to notifications for agent "${agentName}" (openclawId: "${openclawAgentId}")`);
11762
- let processing = false;
11763
- const unsubscribe = client.onUpdate(
11764
- getUndeliveredForPlugin,
11765
- { agent: agentName, apiKey: cfg.apiKey },
11766
- async (notifications) => {
11767
- if (processing) return;
11768
- processing = true;
11769
- try {
11770
- for (const n of notifications) {
11771
- try {
11772
- await injectNotification(port, hooksToken, n, openclawAgentId);
11773
- logger.info(`cohort-sync: injected notification for task #${n.taskNumber} (${agentName})`);
11774
- await client.mutation(markDeliveredByPlugin, {
11775
- notificationId: n._id,
11776
- apiKey: cfg.apiKey
11777
- });
11778
- } catch (err) {
11779
- logger.warn(`cohort-sync: failed to inject notification ${n._id}: ${String(err)}`);
11780
- }
11781
- }
11782
- } finally {
11783
- processing = false;
11784
- }
11785
- },
11786
- (err) => {
11787
- logger.error(`cohort-sync: subscription error for "${agentName}": ${String(err)}`);
11788
- }
11789
- );
11790
- unsubscribers.push(unsubscribe);
11791
- }
11792
- state.unsubscribers = [...unsubscribers];
11793
- }
11794
- function initCommandSubscription(cfg, logger, resolveAgentName) {
11795
- const c = getOrCreateClient();
11796
- if (!c) {
11797
- logger.warn("cohort-sync: no ConvexClient \u2014 command subscription skipped");
11798
- return null;
11799
- }
11800
- let processing = false;
11801
- const unsubscribe = c.onUpdate(
11802
- getPendingCommandsForPlugin,
11803
- { apiKey: cfg.apiKey },
11804
- async (commands) => {
11805
- if (processing) return;
11806
- if (commands.length === 0) return;
11807
- processing = true;
11808
- try {
11809
- for (const cmd of commands) {
11810
- logger.info(`cohort-sync: processing command: ${cmd.type} (${cmd._id})`);
11811
- try {
11812
- await c.mutation(acknowledgeCommandRef, {
11813
- commandId: cmd._id,
11814
- apiKey: cfg.apiKey
11815
- });
11816
- if (cmd.type === "restart") {
11817
- logger.info("cohort-sync: restart acknowledged, terminating in 500ms");
11818
- await new Promise((r) => setTimeout(r, 500));
11819
- process.kill(process.pid, "SIGTERM");
11820
- return;
11821
- }
11822
- if (cmd.type.startsWith("cron")) {
11823
- const hotState = getHotState();
11824
- const port = hotState.gatewayPort;
11825
- const token = hotState.gatewayToken;
11826
- if (!port || !token) {
11827
- logger.warn(`cohort-sync: no gateway port/token, cannot execute ${cmd.type}`);
11828
- continue;
11829
- }
11830
- const gwClient = new GatewayClient(port, token, logger);
11831
- try {
11832
- await gwClient.connect();
11833
- const nameMap = cfg.agentNameMap ?? {};
11834
- switch (cmd.type) {
11835
- case "cronEnable":
11836
- await gwClient.request("cron.update", {
11837
- jobId: cmd.payload?.jobId,
11838
- patch: { enabled: true }
11839
- });
11840
- break;
11841
- case "cronDisable":
11842
- await gwClient.request("cron.update", {
11843
- jobId: cmd.payload?.jobId,
11844
- patch: { enabled: false }
11845
- });
11846
- break;
11847
- case "cronDelete":
11848
- await gwClient.request("cron.remove", {
11849
- jobId: cmd.payload?.jobId
11850
- });
11851
- break;
11852
- case "cronRunNow": {
11853
- const runResult = await gwClient.request(
11854
- "cron.run",
11855
- { jobId: cmd.payload?.jobId }
11856
- );
11857
- if (runResult?.ok && runResult?.ran) {
11858
- const jobId = cmd.payload?.jobId;
11859
- let polls = 0;
11860
- const hs = getHotState();
11861
- if (hs.cronRunNowPoll) clearInterval(hs.cronRunNowPoll);
11862
- const pollInterval = setInterval(async () => {
11863
- polls++;
11864
- if (polls >= 15) {
11865
- clearInterval(pollInterval);
11866
- hs.cronRunNowPoll = null;
11867
- return;
11868
- }
11869
- try {
11870
- const pollClient = getHotState().gatewayProtocolClient;
11871
- if (!pollClient || !pollClient.isAlive()) {
11872
- clearInterval(pollInterval);
11873
- hs.cronRunNowPoll = null;
11874
- return;
11875
- }
11876
- const pollResult = await pollClient.request("cron.list");
11877
- const freshJobs = Array.isArray(pollResult) ? pollResult : pollResult?.jobs ?? [];
11878
- const job = freshJobs.find((j) => j.id === jobId);
11879
- if (job && !job.state?.runningAtMs) {
11880
- clearInterval(pollInterval);
11881
- hs.cronRunNowPoll = null;
11882
- const mapped = freshJobs.map((j) => mapCronJob(j, resolveAgentName));
11883
- await pushCronSnapshot(cfg.apiKey, mapped);
11884
- }
11885
- } catch {
11886
- }
11887
- }, 2e3);
11888
- hs.cronRunNowPoll = pollInterval;
11889
- }
11890
- break;
11891
- }
11892
- case "cronCreate": {
11893
- const agentId = reverseResolveAgentName(cmd.payload?.agentId ?? "main", nameMap);
11894
- await gwClient.request("cron.add", {
11895
- job: {
11896
- agentId,
11897
- name: cmd.payload?.name,
11898
- enabled: true,
11899
- schedule: cmd.payload?.schedule,
11900
- payload: { kind: "agentTurn", message: cmd.payload?.message },
11901
- sessionTarget: "isolated",
11902
- wakeMode: "now"
11903
- }
11904
- });
11905
- break;
11906
- }
11907
- case "cronUpdate": {
11908
- const patch = {};
11909
- if (cmd.payload?.name) patch.name = cmd.payload.name;
11910
- if (cmd.payload?.schedule) patch.schedule = cmd.payload.schedule;
11911
- if (cmd.payload?.message) patch.payload = { kind: "agentTurn", message: cmd.payload.message };
11912
- if (cmd.payload?.agentId) patch.agentId = reverseResolveAgentName(cmd.payload.agentId, nameMap);
11913
- await gwClient.request("cron.update", {
11914
- jobId: cmd.payload?.jobId,
11915
- patch
11916
- });
11917
- break;
11918
- }
11919
- default:
11920
- logger.warn(`cohort-sync: unknown cron command type: ${cmd.type}`);
11921
- }
11922
- if (gwClient.isAlive()) {
11923
- try {
11924
- const snapResult = await gwClient.request("cron.list");
11925
- const freshJobs = Array.isArray(snapResult) ? snapResult : snapResult?.jobs ?? [];
11926
- const mapped = freshJobs.map((j) => mapCronJob(j, resolveAgentName));
11927
- await pushCronSnapshot(cfg.apiKey, mapped);
11928
- } catch (snapErr) {
11929
- logger.warn(`cohort-sync: post-command snapshot push failed: ${String(snapErr)}`);
11930
- }
11931
- }
11932
- } finally {
11933
- gwClient.close();
11934
- }
11935
- } else {
11936
- logger.warn(`cohort-sync: unknown command type: ${cmd.type}`);
11937
- }
11938
- } catch (err) {
11939
- logger.error(`cohort-sync: failed to process command ${cmd._id}: ${String(err)}`);
11940
- try {
11941
- await c.mutation(failCommandRef, {
11942
- commandId: cmd._id,
11943
- apiKey: cfg.apiKey,
11944
- reason: String(err).slice(0, 500)
11945
- });
11946
- } catch (failErr) {
11947
- logger.error(`cohort-sync: failed to mark command ${cmd._id} as failed: ${String(failErr)}`);
11948
- }
11949
- }
11950
- }
11951
- } finally {
11952
- processing = false;
11953
- }
11954
- },
11955
- (err) => {
11956
- logger.error(`cohort-sync: command subscription error: ${String(err)}`);
11957
- }
11958
- );
11959
- logger.info("cohort-sync: command subscription active");
11960
- return unsubscribe;
11914
+ client = new ConvexClient(convexUrl);
11915
+ return client;
11961
11916
  }
11962
- async function callAddCommentFromPlugin(apiKey, args) {
11963
- const c = getOrCreateClient();
11964
- if (!c) {
11965
- throw new Error("Convex client not initialized \u2014 subscription may not be active");
11966
- }
11967
- return await c.mutation(addCommentFromPluginRef, {
11968
- apiKey,
11969
- taskNumber: args.taskNumber,
11970
- agentName: args.agentName,
11971
- content: args.content,
11972
- noReply: args.noReply
11973
- });
11917
+ function getClient() {
11918
+ return client;
11974
11919
  }
11975
- function closeSubscription() {
11920
+ function closeBridge() {
11976
11921
  for (const unsub of unsubscribers) {
11977
11922
  try {
11978
11923
  unsub();
@@ -11980,33 +11925,32 @@ function closeSubscription() {
11980
11925
  }
11981
11926
  }
11982
11927
  unsubscribers.length = 0;
11983
- const state = getHotState();
11984
- for (const unsub of state.unsubscribers) {
11928
+ if (commandUnsubscriber) {
11985
11929
  try {
11986
- unsub();
11930
+ commandUnsubscriber();
11987
11931
  } catch {
11988
11932
  }
11933
+ commandUnsubscriber = null;
11989
11934
  }
11990
- if (state.commandSubscription) {
11991
- try {
11992
- state.commandSubscription();
11993
- } catch {
11994
- }
11995
- state.commandSubscription = null;
11935
+ if (client) {
11936
+ client.close();
11937
+ client = null;
11996
11938
  }
11997
- if (state.gatewayProtocolClient) {
11998
- try {
11999
- state.gatewayProtocolClient.close();
12000
- } catch {
12001
- }
12002
- state.gatewayProtocolClient = null;
12003
- }
12004
- client?.close();
12005
- client = null;
12006
- clearHotState();
11939
+ savedConvexUrl = null;
12007
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");
12008
11952
  async function pushTelemetry(apiKey, data) {
12009
- const c = getOrCreateClient();
11953
+ const c = getClient();
12010
11954
  if (!c) return;
12011
11955
  try {
12012
11956
  await c.mutation(upsertTelemetryFromPlugin, { apiKey, ...data });
@@ -12015,7 +11959,7 @@ async function pushTelemetry(apiKey, data) {
12015
11959
  }
12016
11960
  }
12017
11961
  async function pushSessions(apiKey, agentName, sessions) {
12018
- const c = getOrCreateClient();
11962
+ const c = getClient();
12019
11963
  if (!c) return;
12020
11964
  try {
12021
11965
  await c.mutation(upsertSessionsFromPlugin, { apiKey, agentName, sessions });
@@ -12025,7 +11969,7 @@ async function pushSessions(apiKey, agentName, sessions) {
12025
11969
  }
12026
11970
  async function pushActivity(apiKey, entries) {
12027
11971
  if (entries.length === 0) return;
12028
- const c = getOrCreateClient();
11972
+ const c = getClient();
12029
11973
  if (!c) return;
12030
11974
  try {
12031
11975
  await c.mutation(pushActivityFromPluginRef, { apiKey, entries });
@@ -12034,273 +11978,203 @@ async function pushActivity(apiKey, entries) {
12034
11978
  }
12035
11979
  }
12036
11980
  async function pushCronSnapshot(apiKey, jobs) {
12037
- const c = getOrCreateClient();
12038
- if (!c) return;
11981
+ const c = getClient();
11982
+ if (!c) return false;
12039
11983
  try {
12040
11984
  await c.mutation(upsertCronSnapshotFromPluginRef, { apiKey, jobs });
11985
+ return true;
12041
11986
  } catch (err) {
12042
11987
  getLogger().error(`cohort-sync: pushCronSnapshot failed: ${err}`);
11988
+ return false;
12043
11989
  }
12044
11990
  }
12045
- function saveIntervalsToHot(heartbeat, activityFlush) {
12046
- const state = getHotState();
12047
- state.intervals = { heartbeat, activityFlush };
12048
- }
12049
- function clearIntervalsFromHot() {
12050
- const state = getHotState();
12051
- if (state.intervals.heartbeat) clearInterval(state.intervals.heartbeat);
12052
- if (state.intervals.activityFlush) clearInterval(state.intervals.activityFlush);
12053
- if (state.cronRunNowPoll) clearInterval(state.cronRunNowPoll);
12054
- state.intervals = { heartbeat: null, activityFlush: null };
12055
- state.cronRunNowPoll = null;
12056
- }
12057
- function addActivityToHot(entry) {
12058
- const state = getHotState();
12059
- state.activityBuffer.push(entry);
12060
- const logger = savedLogger;
12061
- if (logger) {
12062
- logger.info(`cohort-sync: +activity [${entry.category}] "${entry.text}"`);
12063
- }
12064
- }
12065
- function drainActivityFromHot() {
12066
- const state = getHotState();
12067
- const buf = state.activityBuffer;
12068
- state.activityBuffer = [];
12069
- return buf;
12070
- }
12071
- function setChannelAgent(channelId, agentName) {
12072
- getHotState().channelAgentBridge[channelId] = agentName;
12073
- }
12074
- function getChannelAgent(channelId) {
12075
- return getHotState().channelAgentBridge[channelId] ?? null;
12076
- }
12077
-
12078
- // src/sync.ts
12079
- function extractJson(raw) {
12080
- const jsonStart = raw.search(/[\[{]/);
12081
- const jsonEndBracket = raw.lastIndexOf("]");
12082
- const jsonEndBrace = raw.lastIndexOf("}");
12083
- const jsonEnd = Math.max(jsonEndBracket, jsonEndBrace);
12084
- if (jsonStart === -1 || jsonEnd === -1 || jsonEnd < jsonStart) {
12085
- throw new Error("No JSON found in output");
12086
- }
12087
- return raw.slice(jsonStart, jsonEnd + 1);
12088
- }
12089
- function fetchSkills(logger) {
12090
- try {
12091
- const raw = execSync("openclaw skills list --json", {
12092
- encoding: "utf8",
12093
- timeout: 3e4,
12094
- stdio: ["ignore", "pipe", "ignore"],
12095
- env: { ...process.env, NO_COLOR: "1" }
12096
- });
12097
- const parsed = JSON.parse(extractJson(raw));
12098
- const list = Array.isArray(parsed) ? parsed : parsed?.skills ?? [];
12099
- return list.map((s) => ({
12100
- name: String(s.name ?? s.id ?? "unknown"),
12101
- description: String(s.description ?? ""),
12102
- source: String(s.source ?? s.origin ?? "unknown"),
12103
- ...s.emoji ? { emoji: String(s.emoji) } : {}
12104
- }));
12105
- } catch (err) {
12106
- logger.warn(`cohort-sync: failed to fetch skills: ${String(err)}`);
12107
- return [];
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");
12108
11995
  }
12109
- }
12110
- var VALID_STATUSES = /* @__PURE__ */ new Set(["idle", "working", "waiting"]);
12111
- function normalizeStatus(status) {
12112
- return VALID_STATUSES.has(status) ? status : "idle";
12113
- }
12114
- async function v1Get(apiUrl, apiKey, path4) {
12115
- const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
12116
- headers: { Authorization: `Bearer ${apiKey}` },
12117
- signal: AbortSignal.timeout(1e4)
12118
- });
12119
- if (!res.ok) throw new Error(`GET ${path4} \u2192 ${res.status}`);
12120
- return res.json();
12121
- }
12122
- async function v1Patch(apiUrl, apiKey, path4, body) {
12123
- const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
12124
- method: "PATCH",
12125
- headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
12126
- body: JSON.stringify(body),
12127
- signal: AbortSignal.timeout(1e4)
12128
- });
12129
- if (!res.ok) throw new Error(`PATCH ${path4} \u2192 ${res.status}`);
12130
- }
12131
- async function v1Post(apiUrl, apiKey, path4, body) {
12132
- const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
12133
- method: "POST",
12134
- headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
12135
- body: JSON.stringify(body),
12136
- signal: AbortSignal.timeout(1e4)
11996
+ return await c.mutation(addCommentFromPluginRef, {
11997
+ apiKey,
11998
+ taskNumber: args.taskNumber,
11999
+ agentName: args.agentName,
12000
+ content: args.content,
12001
+ noReply: args.noReply
12137
12002
  });
12138
- if (!res.ok) throw new Error(`POST ${path4} \u2192 ${res.status}`);
12139
12003
  }
12140
- async function checkForUpdate(currentVersion, logger) {
12141
- try {
12142
- const res = await fetch("https://registry.npmjs.org/@cfio/cohort-sync/latest", {
12143
- signal: AbortSignal.timeout(5e3)
12144
- });
12145
- if (!res.ok) return;
12146
- const data = await res.json();
12147
- const latest = data.version;
12148
- if (latest && latest !== currentVersion) {
12149
- logger.warn(
12150
- `cohort-sync: update available (${currentVersion} \u2192 ${latest}) \u2014 run "npm install -g @cfio/cohort-sync" to update`
12151
- );
12152
- }
12153
- } catch {
12154
- }
12155
- }
12156
- async function syncAgentStatus(agentName, status, model, cfg, logger) {
12157
- try {
12158
- const data = await v1Get(cfg.apiUrl, cfg.apiKey, "/api/v1/agents?limit=100");
12159
- const agents = data?.data ?? [];
12160
- const agent = agents.find(
12161
- (a) => a.name.toLowerCase() === agentName.toLowerCase()
12162
- );
12163
- if (!agent) {
12164
- const available = agents.map((a) => a.name).join(", ") || "(none)";
12165
- logger.warn(
12166
- `cohort-sync: agent "${agentName}" not found in workspace \u2014 skipping status sync. Available agents: [${available}]. Configure agentNameMap in your plugin config to map OpenClaw agent IDs to Cohort names.`
12167
- );
12168
- return;
12169
- }
12170
- await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${agent.id}`, {
12171
- status: normalizeStatus(status),
12172
- model
12173
- });
12174
- logger.info(`cohort-sync: agent "${agentName}" \u2192 ${normalizeStatus(status)}`);
12175
- } catch (err) {
12176
- logger.warn(`cohort-sync: syncAgentStatus failed: ${String(err)}`);
12177
- }
12178
- }
12179
- async function syncSkillsToV1(skills, cfg, logger) {
12180
- for (const skill of skills) {
12181
- try {
12182
- await v1Post(cfg.apiUrl, cfg.apiKey, "/api/v1/skills", {
12183
- name: skill.name,
12184
- description: skill.description
12185
- });
12186
- } catch (err) {
12187
- logger.warn(`cohort-sync: failed to sync skill "${skill.name}": ${String(err)}`);
12188
- }
12189
- }
12190
- }
12191
- var lastKnownRoster = [];
12192
- function getLastKnownRoster() {
12193
- return lastKnownRoster;
12194
- }
12195
- function restoreRosterFromHotReload(hotRoster, logger) {
12196
- if (hotRoster && hotRoster.length > 0 && lastKnownRoster.length === 0) {
12197
- lastKnownRoster = hotRoster;
12198
- logger.info(`cohort-sync: recovered roster (${hotRoster.length} agents) after hot-reload`);
12199
- }
12200
- }
12201
- async function reconcileRoster(openClawAgents, cfg, logger) {
12202
- const data = await v1Get(cfg.apiUrl, cfg.apiKey, "/api/v1/agents?limit=100");
12203
- const cohortAgents = data?.data ?? [];
12204
- const cohortByName = new Map(cohortAgents.map((a) => [a.name.toLowerCase(), a]));
12205
- const openClawNames = new Set(
12206
- openClawAgents.map((a) => {
12207
- const nameMap = cfg.agentNameMap;
12208
- return (nameMap?.[a.id] ?? a.identity?.name ?? a.id).toLowerCase();
12209
- })
12210
- );
12211
- for (const oc of openClawAgents) {
12212
- const agentName = (cfg.agentNameMap?.[oc.id] ?? oc.identity?.name ?? oc.id).toLowerCase();
12213
- const existing = cohortByName.get(agentName);
12214
- if (!existing) {
12215
- try {
12216
- await v1Post(cfg.apiUrl, cfg.apiKey, "/api/v1/agents", {
12217
- name: agentName,
12218
- displayName: oc.identity?.name ?? agentName,
12219
- emoji: oc.identity?.emoji ?? "\u{1F916}",
12220
- model: oc.model,
12221
- status: "idle"
12222
- });
12223
- logger.info(`cohort-sync: provisioned new agent "${agentName}"`);
12224
- } catch (err) {
12225
- logger.warn(`cohort-sync: failed to provision agent "${agentName}": ${String(err)}`);
12226
- }
12227
- } else {
12228
- const updates = {
12229
- model: oc.model,
12230
- status: "idle"
12231
- };
12232
- if (oc.identity?.name) {
12233
- updates.displayName = oc.identity.name;
12234
- }
12235
- if (oc.identity?.emoji) {
12236
- updates.emoji = oc.identity.emoji;
12237
- }
12238
- try {
12239
- await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${existing.id}`, updates);
12240
- } catch (err) {
12241
- logger.warn(`cohort-sync: failed to update agent "${agentName}": ${String(err)}`);
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.";
12242
12021
  }
12243
- }
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.";
12244
12037
  }
12245
- for (const cohort of cohortAgents) {
12246
- if (!openClawNames.has(cohort.name.toLowerCase())) {
12247
- if (cohort.status === "archived" || cohort.status === "deleted" || cohort.status === "unreachable") {
12248
- continue;
12249
- }
12250
- try {
12251
- await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${cohort.id}`, {
12252
- status: "unreachable"
12253
- });
12254
- logger.info(`cohort-sync: marked agent "${cohort.name}" as unreachable`);
12255
- } catch (err) {
12256
- logger.warn(
12257
- `cohort-sync: failed to mark agent "${cohort.name}" as unreachable: ${String(err)}`
12258
- );
12259
- }
12260
- }
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}`;
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}`;
12057
+ }
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}`);
12261
12075
  }
12262
- const updatedData = await v1Get(cfg.apiUrl, cfg.apiKey, "/api/v1/agents?limit=100");
12263
- const finalRoster = updatedData?.data ?? cohortAgents;
12264
- lastKnownRoster = finalRoster;
12265
- setRosterHotState(finalRoster);
12266
- return finalRoster;
12267
12076
  }
12268
- async function markAllUnreachable(cfg, logger) {
12269
- const roster = getLastKnownRoster();
12270
- if (roster.length === 0) {
12271
- logger.warn("cohort-sync: no cached roster \u2014 skipping markAllUnreachable");
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");
12272
12081
  return;
12273
12082
  }
12274
- for (const agent of roster) {
12275
- if (agent.status === "unreachable" || agent.status === "archived" || agent.status === "deleted") {
12276
- continue;
12277
- }
12278
- try {
12279
- await v1Patch(cfg.apiUrl, cfg.apiKey, `/api/v1/agents/${agent.id}`, {
12280
- status: "unreachable"
12281
- });
12282
- } catch (err) {
12283
- logger.warn(`cohort-sync: failed to mark "${agent.name}" as unreachable: ${String(err)}`);
12284
- }
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;
12285
12088
  }
12286
- logger.info("cohort-sync: all agents marked unreachable");
12287
- }
12288
- async function fullSync(agentName, model, cfg, logger, openClawAgents) {
12289
- logger.info("cohort-sync: full sync starting");
12290
- if (openClawAgents && openClawAgents.length > 0) {
12291
- try {
12292
- await reconcileRoster(openClawAgents, cfg, logger);
12293
- } catch (err) {
12294
- logger.warn(`cohort-sync: roster reconciliation failed: ${String(err)}`);
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;
12295
12094
  }
12296
- } else {
12297
- await syncAgentStatus(agentName, "working", model, cfg, logger);
12298
12095
  }
12299
- const skills = fetchSkills(logger);
12300
- if (skills.length > 0) {
12301
- 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);
12302
12128
  }
12303
- 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;
12304
12178
  }
12305
12179
 
12306
12180
  // src/gateway-client.ts
@@ -12487,13 +12361,7 @@ function parseHelloOk(response) {
12487
12361
  snapshot: payload.snapshot
12488
12362
  };
12489
12363
  }
12490
- function getPendingRequests() {
12491
- const g = globalThis;
12492
- const hot = g.__cohort_sync__ ?? (g.__cohort_sync__ = {});
12493
- if (!hot.pendingGatewayRequests) hot.pendingGatewayRequests = /* @__PURE__ */ new Map();
12494
- return hot.pendingGatewayRequests;
12495
- }
12496
- var GatewayClient2 = class {
12364
+ var GatewayClient = class {
12497
12365
  port;
12498
12366
  logger;
12499
12367
  ws = null;
@@ -12507,9 +12375,12 @@ var GatewayClient2 = class {
12507
12375
  tickIntervalMs = 15e3;
12508
12376
  // default; overwritten by hello-ok response
12509
12377
  deviceIdentity;
12378
+ pendingRequests = /* @__PURE__ */ new Map();
12510
12379
  /** Public Sets populated from hello-ok — consumers can inspect gateway capabilities */
12511
12380
  availableMethods = /* @__PURE__ */ new Set();
12512
12381
  availableEvents = /* @__PURE__ */ new Set();
12382
+ /** Called after a successful reconnection (WebSocket re-established after drop) */
12383
+ onReconnect = null;
12513
12384
  /**
12514
12385
  * @param port - Gateway WebSocket port
12515
12386
  * @param token - Auth token (stored in closure via constructor param, NOT on this or globalThis)
@@ -12636,12 +12507,11 @@ var GatewayClient2 = class {
12636
12507
  this.stopTickWatchdog();
12637
12508
  diag("GW_CLIENT_WS_CLOSED", { port: this.port });
12638
12509
  this.logger.warn("cohort-sync: gateway client WebSocket closed");
12639
- const pending = getPendingRequests();
12640
- for (const [, entry] of pending) {
12510
+ for (const [, entry] of this.pendingRequests) {
12641
12511
  clearTimeout(entry.timer);
12642
12512
  entry.reject(new Error("Gateway WebSocket closed"));
12643
12513
  }
12644
- pending.clear();
12514
+ this.pendingRequests.clear();
12645
12515
  if (!settled) {
12646
12516
  settle(new Error("Gateway WebSocket closed during handshake"));
12647
12517
  }
@@ -12672,6 +12542,18 @@ var GatewayClient2 = class {
12672
12542
  }
12673
12543
  handlers.add(handler);
12674
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
+ }
12675
12557
  /**
12676
12558
  * Send a request to the gateway and wait for the response.
12677
12559
  *
@@ -12692,13 +12574,12 @@ var GatewayClient2 = class {
12692
12574
  method,
12693
12575
  params
12694
12576
  };
12695
- const pending = getPendingRequests();
12696
12577
  return new Promise((resolve, reject) => {
12697
12578
  const timer = setTimeout(() => {
12698
- pending.delete(id);
12579
+ this.pendingRequests.delete(id);
12699
12580
  reject(new Error(`Gateway method "${method}" timed out after ${timeoutMs}ms`));
12700
12581
  }, timeoutMs);
12701
- pending.set(id, {
12582
+ this.pendingRequests.set(id, {
12702
12583
  resolve,
12703
12584
  reject,
12704
12585
  timer
@@ -12717,12 +12598,11 @@ var GatewayClient2 = class {
12717
12598
  clearTimeout(this.reconnectTimer);
12718
12599
  this.reconnectTimer = null;
12719
12600
  }
12720
- const pending = getPendingRequests();
12721
- for (const [, entry] of pending) {
12601
+ for (const [, entry] of this.pendingRequests) {
12722
12602
  clearTimeout(entry.timer);
12723
12603
  entry.reject(new Error("Gateway client closed"));
12724
12604
  }
12725
- pending.clear();
12605
+ this.pendingRequests.clear();
12726
12606
  if (this.ws) {
12727
12607
  this.ws.close();
12728
12608
  this.ws = null;
@@ -12752,10 +12632,9 @@ var GatewayClient2 = class {
12752
12632
  }
12753
12633
  }
12754
12634
  handleResponse(frame) {
12755
- const pending = getPendingRequests();
12756
- const entry = pending.get(frame.id);
12635
+ const entry = this.pendingRequests.get(frame.id);
12757
12636
  if (!entry) return;
12758
- pending.delete(frame.id);
12637
+ this.pendingRequests.delete(frame.id);
12759
12638
  clearTimeout(entry.timer);
12760
12639
  if (frame.ok) {
12761
12640
  entry.resolve(frame.payload);
@@ -12830,6 +12709,7 @@ var GatewayClient2 = class {
12830
12709
  diag("GW_CLIENT_RECONNECTING", { attempt: this.reconnectAttempts });
12831
12710
  await this.connect();
12832
12711
  diag("GW_CLIENT_RECONNECTED", { attempt: this.reconnectAttempts });
12712
+ this.onReconnect?.();
12833
12713
  } catch (err) {
12834
12714
  diag("GW_CLIENT_RECONNECT_FAILED", {
12835
12715
  attempt: this.reconnectAttempts,
@@ -12840,6 +12720,94 @@ var GatewayClient2 = class {
12840
12720
  }
12841
12721
  };
12842
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
+
12843
12811
  // src/agent-state.ts
12844
12812
  import { basename } from "node:path";
12845
12813
 
@@ -13088,6 +13056,16 @@ function buildActivityEntry(agentName, hook, context) {
13088
13056
  return null;
13089
13057
  }
13090
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
+ }
13091
13069
  var AgentStateTracker = class {
13092
13070
  agents = /* @__PURE__ */ new Map();
13093
13071
  activityBuffer = [];
@@ -13338,11 +13316,26 @@ var POCKET_GUIDE = `# Cohort Agent Guide (Pocket Version)
13338
13316
  `;
13339
13317
 
13340
13318
  // src/hooks.ts
13341
- 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
+ ]);
13342
13331
  function dumpCtx(ctx) {
13343
13332
  if (!ctx || typeof ctx !== "object") return { _raw: String(ctx) };
13344
13333
  const out = {};
13345
13334
  for (const key of Object.keys(ctx)) {
13335
+ if (REDACT_KEYS.has(key.toLowerCase())) {
13336
+ out[key] = "[REDACTED]";
13337
+ continue;
13338
+ }
13346
13339
  const val = ctx[key];
13347
13340
  if (typeof val === "function") {
13348
13341
  out[key] = "[Function]";
@@ -13368,8 +13361,7 @@ try {
13368
13361
  PLUGIN_VERSION = pkgJson.version ?? "unknown";
13369
13362
  } catch {
13370
13363
  }
13371
- diag("MODULE_LOADED", { BUILD_ID, PLUGIN_VERSION });
13372
- var lastCronSnapshotJson = "";
13364
+ diag("MODULE_LOADED", { PLUGIN_VERSION });
13373
13365
  function resolveGatewayToken(api) {
13374
13366
  const rawToken = api.config?.gateway?.auth?.token;
13375
13367
  if (typeof rawToken === "string") return rawToken;
@@ -13378,26 +13370,6 @@ function resolveGatewayToken(api) {
13378
13370
  }
13379
13371
  return null;
13380
13372
  }
13381
- async function quickCronSync(port, token, cfg, resolveAgentName, logger) {
13382
- const client2 = new GatewayClient2(port, token, logger, PLUGIN_VERSION);
13383
- try {
13384
- await client2.connect();
13385
- const result = await client2.request("cron.list", { includeDisabled: true });
13386
- diag("GW_CLIENT_CRON_LIST_RESULT", { type: typeof result, isArray: Array.isArray(result), keys: result && typeof result === "object" ? Object.keys(result) : [], length: Array.isArray(result) ? result.length : void 0 });
13387
- const jobs = Array.isArray(result) ? result : result?.jobs ?? [];
13388
- const mapped = jobs.map((j) => mapCronJob(j, resolveAgentName));
13389
- const serialized = JSON.stringify(mapped);
13390
- if (serialized !== lastCronSnapshotJson) {
13391
- await pushCronSnapshot(cfg.apiKey, mapped);
13392
- lastCronSnapshotJson = serialized;
13393
- diag("HEARTBEAT_CRON_PUSHED", { count: mapped.length });
13394
- } else {
13395
- diag("HEARTBEAT_CRON_UNCHANGED", {});
13396
- }
13397
- } finally {
13398
- client2.close();
13399
- }
13400
- }
13401
13373
  function registerCronEventHandlers(client2, cfg, resolveAgentName) {
13402
13374
  if (client2.availableEvents.has("cron")) {
13403
13375
  let debounceTimer = null;
@@ -13449,23 +13421,25 @@ function resolveIdentity(configIdentity, workspaceDir) {
13449
13421
  avatar: configIdentity?.avatar ?? fileIdentity?.avatar
13450
13422
  };
13451
13423
  }
13424
+ var tracker = null;
13452
13425
  function getOrCreateTracker() {
13453
- const state = getHotState();
13454
- if (state.tracker instanceof AgentStateTracker) {
13455
- return state.tracker;
13456
- }
13457
- const fresh = new AgentStateTracker();
13458
- state.tracker = fresh;
13459
- return fresh;
13460
- }
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;
13461
13435
  var STATE_FILE_PATH = "";
13462
- function saveSessionsToDisk(tracker) {
13436
+ function saveSessionsToDisk(tracker2) {
13463
13437
  try {
13464
- const state = tracker.exportState();
13438
+ const state = tracker2.exportState();
13465
13439
  const data = {
13466
13440
  sessions: [],
13467
13441
  sessionKeyToAgent: Object.fromEntries(state.sessionKeyToAgent),
13468
- channelAgents: { ...globalThis["__cohort_sync_channel_agent__"] ?? {} },
13442
+ channelAgents: Object.fromEntries(getChannelAgentBridge()),
13469
13443
  savedAt: (/* @__PURE__ */ new Date()).toISOString()
13470
13444
  };
13471
13445
  for (const [name, agent] of state.agents) {
@@ -13477,7 +13451,7 @@ function saveSessionsToDisk(tracker) {
13477
13451
  } catch {
13478
13452
  }
13479
13453
  }
13480
- function loadSessionsFromDisk(tracker, logger) {
13454
+ function loadSessionsFromDisk(tracker2, logger) {
13481
13455
  try {
13482
13456
  if (!fs3.existsSync(STATE_FILE_PATH)) return;
13483
13457
  const data = JSON.parse(fs3.readFileSync(STATE_FILE_PATH, "utf8"));
@@ -13487,13 +13461,13 @@ function loadSessionsFromDisk(tracker, logger) {
13487
13461
  }
13488
13462
  let count = 0;
13489
13463
  for (const { agentName, key } of data.sessions) {
13490
- if (!tracker.hasSession(agentName, key)) {
13491
- tracker.addSession(agentName, key);
13464
+ if (!tracker2.hasSession(agentName, key)) {
13465
+ tracker2.addSession(agentName, key);
13492
13466
  count++;
13493
13467
  }
13494
13468
  }
13495
13469
  for (const [key, agent] of Object.entries(data.sessionKeyToAgent)) {
13496
- tracker.setSessionAgent(key, agent);
13470
+ tracker2.setSessionAgent(key, agent);
13497
13471
  }
13498
13472
  for (const [channelId, agent] of Object.entries(data.channelAgents ?? {})) {
13499
13473
  setChannelAgent(channelId, agent);
@@ -13521,16 +13495,48 @@ async function fetchAgentContext(apiKey, apiUrl, logger) {
13521
13495
  return POCKET_GUIDE;
13522
13496
  }
13523
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
+ }
13524
13530
  function registerHooks(api, cfg) {
13525
13531
  STATE_FILE_PATH = path3.join(cfg.stateDir, "session-state.json");
13526
13532
  const { logger, config } = api;
13527
13533
  const nameMap = cfg.agentNameMap;
13528
- const tracker = getOrCreateTracker();
13529
- let heartbeatInterval = null;
13530
- let activityFlushInterval = null;
13531
- 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}`);
13532
13539
  diag("REGISTER_HOOKS", {
13533
- BUILD_ID,
13534
13540
  PLUGIN_VERSION,
13535
13541
  hasNameMap: !!nameMap,
13536
13542
  nameMapKeys: nameMap ? Object.keys(nameMap) : [],
@@ -13539,10 +13545,6 @@ function registerHooks(api, cfg) {
13539
13545
  agentIds: (config?.agents?.list ?? []).map((a) => a.id),
13540
13546
  agentMessageProviders: (config?.agents?.list ?? []).map((a) => ({ id: a.id, mp: a.messageProvider }))
13541
13547
  });
13542
- setConvexUrl(cfg);
13543
- setLogger(logger);
13544
- restoreFromHotReload(logger);
13545
- restoreRosterFromHotReload(getRosterHotState(), logger);
13546
13548
  const identityNameMap = {};
13547
13549
  const mainIdentity = parseIdentityFile(process.cwd());
13548
13550
  if (mainIdentity?.name) {
@@ -13555,14 +13557,14 @@ function registerHooks(api, cfg) {
13555
13557
  }
13556
13558
  }
13557
13559
  diag("IDENTITY_NAME_MAP", { identityNameMap });
13558
- if (tracker.getAgentNames().length === 0) {
13559
- loadSessionsFromDisk(tracker, logger);
13560
- const restoredAgents = tracker.getAgentNames();
13560
+ if (tracker2.getAgentNames().length === 0) {
13561
+ loadSessionsFromDisk(tracker2, logger);
13562
+ const restoredAgents = tracker2.getAgentNames();
13561
13563
  for (const agentName of restoredAgents) {
13562
- const sessSnapshot = tracker.getSessionsSnapshot(agentName);
13564
+ const sessSnapshot = tracker2.getSessionsSnapshot(agentName);
13563
13565
  if (sessSnapshot.length > 0) {
13564
13566
  pushSessions(cfg.apiKey, agentName, sessSnapshot).then(() => {
13565
- tracker.markSessionsPushed(agentName);
13567
+ tracker2.markSessionsPushed(agentName);
13566
13568
  logger.info(`cohort-sync: pushed ${sessSnapshot.length} restored sessions for ${agentName}`);
13567
13569
  }).catch((err) => {
13568
13570
  logger.warn(`cohort-sync: failed to push restored sessions for ${agentName}: ${String(err)}`);
@@ -13593,7 +13595,7 @@ function registerHooks(api, cfg) {
13593
13595
  }
13594
13596
  const sessionKey = ctx.sessionKey ?? ctx.sessionId;
13595
13597
  if (sessionKey && typeof sessionKey === "string") {
13596
- const mapped = tracker.getSessionAgent(sessionKey);
13598
+ const mapped = tracker2.getSessionAgent(sessionKey);
13597
13599
  if (mapped) {
13598
13600
  diag("RESOLVE_AGENT_FROM_CTX_RESULT", { method: "sessionKey_mapped", sessionKey, mapped });
13599
13601
  return mapped;
@@ -13615,10 +13617,10 @@ function registerHooks(api, cfg) {
13615
13617
  if (channelId && typeof channelId === "string") {
13616
13618
  const channelAgent = getChannelAgent(channelId);
13617
13619
  if (channelAgent) {
13618
- 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()) });
13619
13621
  return channelAgent;
13620
13622
  }
13621
- 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()) });
13622
13624
  return String(channelId);
13623
13625
  }
13624
13626
  const resolved = resolveAgentName("main");
@@ -13639,48 +13641,20 @@ function registerHooks(api, cfg) {
13639
13641
  }
13640
13642
  return "unknown";
13641
13643
  }
13642
- const pendingActivity = drainActivityFromHot();
13643
- if (pendingActivity.length > 0) {
13644
- pushActivity(cfg.apiKey, pendingActivity).catch((err) => {
13645
- logger.warn(`cohort-sync: pre-reload activity flush failed: ${String(err)}`);
13646
- });
13647
- logger.info(`cohort-sync: flushed ${pendingActivity.length} pending activity entries before hot-reload`);
13648
- }
13649
- clearIntervalsFromHot();
13650
- const heartbeatMs = cfg.syncIntervalMs ?? 12e4;
13651
- heartbeatInterval = setInterval(() => {
13652
- pushHeartbeat().catch((err) => {
13653
- logger.warn(`cohort-sync: heartbeat tick failed: ${String(err)}`);
13654
- });
13655
- }, heartbeatMs);
13656
- activityFlushInterval = setInterval(() => {
13657
- flushActivityBuffer().catch((err) => {
13658
- logger.warn(`cohort-sync: activity flush tick failed: ${String(err)}`);
13659
- });
13660
- }, 3e3);
13661
- saveIntervalsToHot(heartbeatInterval, activityFlushInterval);
13662
- logger.info(`cohort-sync: intervals created (heartbeat=${heartbeatMs / 1e3}s, activityFlush=3s)`);
13663
- {
13664
- const hotState = getHotState();
13665
- if (hotState.commandSubscription) {
13666
- hotState.commandSubscription();
13667
- hotState.commandSubscription = null;
13668
- }
13669
- const unsub = initCommandSubscription(cfg, logger, resolveAgentName);
13670
- hotState.commandSubscription = unsub;
13671
- }
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;
13672
13651
  {
13673
- const port = api.config?.gateway?.port;
13674
- const token = resolveGatewayToken(api);
13675
- if (port && token) {
13676
- const hotState = getHotState();
13677
- hotState.gatewayPort = port;
13678
- hotState.gatewayToken = token;
13679
- diag("REGISTER_HOOKS_CRON_SYNC", { port });
13680
- quickCronSync(port, token, cfg, resolveAgentName, logger).catch((err) => {
13681
- diag("REGISTER_HOOKS_CRON_SYNC_FAILED", { error: String(err) });
13682
- });
13652
+ if (commandUnsubscriber2) {
13653
+ commandUnsubscriber2();
13654
+ commandUnsubscriber2 = null;
13683
13655
  }
13656
+ const unsub = startCommandSubscription(cfg, logger, resolveAgentName, persistentGwClient);
13657
+ commandUnsubscriber2 = unsub;
13684
13658
  }
13685
13659
  api.registerTool((toolCtx) => {
13686
13660
  const agentId = toolCtx.agentId ?? "main";
@@ -13796,52 +13770,6 @@ Do not attempt to make more comments until ${resetAt}.`
13796
13770
  if (m.includes("deepseek")) return 128e3;
13797
13771
  return 2e5;
13798
13772
  }
13799
- async function pushHeartbeat() {
13800
- const allAgentIds = ["main", ...(config?.agents?.list ?? []).map((a) => a.id)];
13801
- for (const agentId of allAgentIds) {
13802
- const agentName = resolveAgentName(agentId);
13803
- const pruned = tracker.pruneStaleSessions(agentName, 864e5);
13804
- if (pruned.length > 0) {
13805
- logger.info(`cohort-sync: pruned ${pruned.length} stale sessions for ${agentName}`);
13806
- }
13807
- }
13808
- for (const agentId of allAgentIds) {
13809
- const agentName = resolveAgentName(agentId);
13810
- try {
13811
- const snapshot = tracker.getTelemetrySnapshot(agentName);
13812
- if (snapshot) {
13813
- await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
13814
- } else {
13815
- logger.info(`cohort-sync: heartbeat skipped ${agentName} \u2014 no snapshot in tracker`);
13816
- }
13817
- } catch (err) {
13818
- logger.warn(`cohort-sync: heartbeat push failed for ${agentName}: ${String(err)}`);
13819
- }
13820
- }
13821
- for (const agentId of allAgentIds) {
13822
- const agentName = resolveAgentName(agentId);
13823
- try {
13824
- if (tracker.shouldPushSessions(agentName)) {
13825
- const sessSnapshot = tracker.getSessionsSnapshot(agentName);
13826
- await pushSessions(cfg.apiKey, agentName, sessSnapshot);
13827
- tracker.markSessionsPushed(agentName);
13828
- }
13829
- } catch (err) {
13830
- logger.warn(`cohort-sync: heartbeat session push failed for ${agentName}: ${String(err)}`);
13831
- }
13832
- }
13833
- saveSessionsToDisk(tracker);
13834
- logger.info(`cohort-sync: heartbeat pushed for ${allAgentIds.length} agents`);
13835
- }
13836
- async function flushActivityBuffer() {
13837
- const entries = drainActivityFromHot();
13838
- if (entries.length === 0) return;
13839
- try {
13840
- await pushActivity(cfg.apiKey, entries);
13841
- } catch (err) {
13842
- logger.warn(`cohort-sync: activity flush failed: ${String(err)}`);
13843
- }
13844
- }
13845
13773
  api.on("gateway_start", async (event) => {
13846
13774
  diag("HOOK_gateway_start", { port: event.port, eventKeys: Object.keys(event) });
13847
13775
  try {
@@ -13867,24 +13795,20 @@ Do not attempt to make more comments until ${resetAt}.`
13867
13795
  setChannelAgent(mp, agentName);
13868
13796
  }
13869
13797
  }
13870
- diag("GATEWAY_START_BRIDGE_AFTER_SEED", { bridge: { ...getHotState().channelAgentBridge } });
13871
- const hotState = getHotState();
13872
- hotState.gatewayPort = event.port;
13798
+ diag("GATEWAY_START_BRIDGE_AFTER_SEED", { bridge: Object.fromEntries(getChannelAgentBridge()) });
13799
+ gatewayPort = event.port;
13873
13800
  const token = resolveGatewayToken(api);
13874
13801
  if (token) {
13802
+ gatewayToken = token;
13875
13803
  diag("GW_CLIENT_CONNECTING", { port: event.port, hasToken: true });
13876
13804
  try {
13877
- const client2 = new GatewayClient2(event.port, token, logger, PLUGIN_VERSION);
13878
- await client2.connect();
13879
- hotState.gatewayProtocolClient = client2;
13880
- registerCronEventHandlers(client2, cfg, resolveAgentName);
13881
- diag("GW_CLIENT_CONNECTED", { methods: client2.availableMethods.size, events: client2.availableEvents.size });
13882
- const cronResult = await client2.request("cron.list", { includeDisabled: true });
13883
- const jobs = Array.isArray(cronResult) ? cronResult : cronResult?.jobs ?? [];
13884
- const mapped = jobs.map((j) => mapCronJob(j, resolveAgentName));
13885
- await pushCronSnapshot(cfg.apiKey, mapped);
13886
- lastCronSnapshotJson = JSON.stringify(mapped);
13887
- diag("GW_CLIENT_INITIAL_CRON_PUSH", { count: mapped.length });
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;
13888
13812
  } catch (err) {
13889
13813
  diag("GW_CLIENT_CONNECT_FAILED", { error: String(err) });
13890
13814
  logger.error(`cohort-sync: gateway client connect failed: ${String(err)}`);
@@ -13893,7 +13817,7 @@ Do not attempt to make more comments until ${resetAt}.`
13893
13817
  diag("GW_CLIENT_NO_TOKEN", {});
13894
13818
  logger.warn("cohort-sync: no gateway auth token \u2014 cron operations disabled");
13895
13819
  }
13896
- await initSubscription(
13820
+ await startNotificationSubscription(
13897
13821
  event.port,
13898
13822
  cfg,
13899
13823
  api.config.hooks?.token,
@@ -13905,31 +13829,48 @@ Do not attempt to make more comments until ${resetAt}.`
13905
13829
  for (const agentId of allAgentIds) {
13906
13830
  const agentName = resolveAgentName(agentId);
13907
13831
  try {
13908
- tracker.setModel(agentName, resolveModel(agentId));
13909
- tracker.updateStatus(agentName, "idle");
13910
- const snapshot = tracker.getTelemetrySnapshot(agentName);
13832
+ tracker2.setModel(agentName, resolveModel(agentId));
13833
+ tracker2.updateStatus(agentName, "idle");
13834
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
13911
13835
  if (snapshot) {
13912
13836
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
13913
- tracker.markTelemetryPushed(agentName);
13837
+ tracker2.markTelemetryPushed(agentName);
13914
13838
  }
13915
13839
  } catch (err) {
13916
13840
  logger.warn(`cohort-sync: initial telemetry seed failed for ${agentName}: ${String(err)}`);
13917
13841
  }
13918
13842
  }
13919
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)`);
13920
13861
  });
13921
13862
  api.on("agent_end", async (event, ctx) => {
13922
13863
  diag("HOOK_agent_end", { ctx: dumpCtx(ctx), success: event.success, error: event.error, durationMs: event.durationMs });
13923
13864
  const agentId = ctx.agentId ?? "main";
13924
13865
  const agentName = resolveAgentName(agentId);
13925
13866
  try {
13926
- tracker.updateStatus(agentName, "idle");
13867
+ tracker2.updateStatus(agentName, "idle");
13927
13868
  await syncAgentStatus(agentName, "idle", resolveModel(agentId), cfg, logger);
13928
- if (tracker.shouldPushTelemetry(agentName)) {
13929
- const snapshot = tracker.getTelemetrySnapshot(agentName);
13869
+ if (tracker2.shouldPushTelemetry(agentName)) {
13870
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
13930
13871
  if (snapshot) {
13931
13872
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
13932
- tracker.markTelemetryPushed(agentName);
13873
+ tracker2.markTelemetryPushed(agentName);
13933
13874
  }
13934
13875
  }
13935
13876
  if (event.success === false) {
@@ -13939,7 +13880,7 @@ Do not attempt to make more comments until ${resetAt}.`
13939
13880
  durationMs: event.durationMs,
13940
13881
  sessionKey: ctx.sessionKey
13941
13882
  });
13942
- if (entry) addActivityToHot(entry);
13883
+ if (entry) activityBatch.add(entry);
13943
13884
  }
13944
13885
  } catch (err) {
13945
13886
  logger.warn(`cohort-sync: agent_end sync failed: ${String(err)}`);
@@ -13964,31 +13905,31 @@ Do not attempt to make more comments until ${resetAt}.`
13964
13905
  const agentName = resolveAgentName(agentId);
13965
13906
  try {
13966
13907
  const sessionKey = ctx.sessionKey;
13967
- tracker.updateFromLlmOutput(agentName, sessionKey, {
13908
+ tracker2.updateFromLlmOutput(agentName, sessionKey, {
13968
13909
  model,
13969
13910
  tokensIn: usage.input ?? 0,
13970
13911
  tokensOut: usage.output ?? 0,
13971
13912
  contextTokens,
13972
13913
  contextLimit
13973
13914
  });
13974
- if (sessionKey && !tracker.hasSession(agentName, sessionKey)) {
13975
- tracker.addSession(agentName, sessionKey);
13915
+ if (sessionKey && !tracker2.hasSession(agentName, sessionKey)) {
13916
+ tracker2.addSession(agentName, sessionKey);
13976
13917
  logger.info(`cohort-sync: inferred session for ${agentName} from llm_output (${sessionKey})`);
13977
13918
  }
13978
13919
  if (sessionKey) {
13979
- tracker.setSessionAgent(sessionKey, agentName);
13920
+ tracker2.setSessionAgent(sessionKey, agentName);
13980
13921
  }
13981
- if (tracker.shouldPushTelemetry(agentName)) {
13982
- const snapshot = tracker.getTelemetrySnapshot(agentName);
13922
+ if (tracker2.shouldPushTelemetry(agentName)) {
13923
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
13983
13924
  if (snapshot) {
13984
13925
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
13985
- tracker.markTelemetryPushed(agentName);
13926
+ tracker2.markTelemetryPushed(agentName);
13986
13927
  }
13987
13928
  }
13988
- if (tracker.shouldPushSessions(agentName)) {
13989
- const sessionsSnapshot = tracker.getSessionsSnapshot(agentName);
13929
+ if (tracker2.shouldPushSessions(agentName)) {
13930
+ const sessionsSnapshot = tracker2.getSessionsSnapshot(agentName);
13990
13931
  await pushSessions(cfg.apiKey, agentName, sessionsSnapshot);
13991
- tracker.markSessionsPushed(agentName);
13932
+ tracker2.markSessionsPushed(agentName);
13992
13933
  }
13993
13934
  } catch (err) {
13994
13935
  logger.warn(`cohort-sync: llm_output telemetry failed: ${String(err)}`);
@@ -13999,15 +13940,15 @@ Do not attempt to make more comments until ${resetAt}.`
13999
13940
  const agentId = ctx.agentId ?? "main";
14000
13941
  const agentName = resolveAgentName(agentId);
14001
13942
  try {
14002
- tracker.updateFromCompaction(agentName, {
13943
+ tracker2.updateFromCompaction(agentName, {
14003
13944
  contextTokens: event.tokenCount ?? 0,
14004
13945
  contextLimit: getModelContextLimit(resolveModel(agentId))
14005
13946
  });
14006
- if (tracker.shouldPushTelemetry(agentName)) {
14007
- const snapshot = tracker.getTelemetrySnapshot(agentName);
13947
+ if (tracker2.shouldPushTelemetry(agentName)) {
13948
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
14008
13949
  if (snapshot) {
14009
13950
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
14010
- tracker.markTelemetryPushed(agentName);
13951
+ tracker2.markTelemetryPushed(agentName);
14011
13952
  }
14012
13953
  }
14013
13954
  const entry = buildActivityEntry(agentName, "after_compaction", {
@@ -14015,7 +13956,7 @@ Do not attempt to make more comments until ${resetAt}.`
14015
13956
  compactedCount: event.compactedCount,
14016
13957
  sessionKey: ctx.sessionKey
14017
13958
  });
14018
- if (entry) addActivityToHot(entry);
13959
+ if (entry) activityBatch.add(entry);
14019
13960
  } catch (err) {
14020
13961
  logger.warn(`cohort-sync: after_compaction telemetry failed: ${String(err)}`);
14021
13962
  }
@@ -14026,26 +13967,39 @@ Do not attempt to make more comments until ${resetAt}.`
14026
13967
  const agentName = resolveAgentName(agentId);
14027
13968
  diag("HOOK_before_agent_start_RESOLVED", { agentId, agentName, ctxChannelId: ctx.channelId, ctxMessageProvider: ctx.messageProvider, ctxSessionKey: ctx.sessionKey, ctxAccountId: ctx.accountId });
14028
13969
  try {
14029
- tracker.updateStatus(agentName, "working");
14030
- if (tracker.shouldPushTelemetry(agentName)) {
14031
- 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);
14032
13986
  if (snapshot) {
14033
13987
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
14034
- tracker.markTelemetryPushed(agentName);
13988
+ tracker2.markTelemetryPushed(agentName);
14035
13989
  }
14036
13990
  }
14037
13991
  const sessionKey = ctx.sessionKey;
14038
- if (sessionKey && !tracker.hasSession(agentName, sessionKey)) {
14039
- tracker.addSession(agentName, sessionKey);
14040
- tracker.setSessionAgent(sessionKey, agentName);
13992
+ if (sessionKey && !tracker2.hasSession(agentName, sessionKey)) {
13993
+ tracker2.addSession(agentName, sessionKey);
13994
+ tracker2.setSessionAgent(sessionKey, agentName);
14041
13995
  logger.info(`cohort-sync: inferred session for ${agentName} (${sessionKey})`);
14042
- if (tracker.shouldPushSessions(agentName)) {
14043
- const sessSnapshot = tracker.getSessionsSnapshot(agentName);
13996
+ if (tracker2.shouldPushSessions(agentName)) {
13997
+ const sessSnapshot = tracker2.getSessionsSnapshot(agentName);
14044
13998
  await pushSessions(cfg.apiKey, agentName, sessSnapshot);
14045
- tracker.markSessionsPushed(agentName);
13999
+ tracker2.markSessionsPushed(agentName);
14046
14000
  }
14047
14001
  } else if (sessionKey) {
14048
- tracker.setSessionAgent(sessionKey, agentName);
14002
+ tracker2.setSessionAgent(sessionKey, agentName);
14049
14003
  }
14050
14004
  const ctxChannelId = ctx.channelId;
14051
14005
  if (ctxChannelId) {
@@ -14065,11 +14019,11 @@ Do not attempt to make more comments until ${resetAt}.`
14065
14019
  const agentName = resolveAgentName(agentId);
14066
14020
  try {
14067
14021
  const sessionKey = ctx.sessionId ?? String(Date.now());
14068
- tracker.addSession(agentName, sessionKey);
14069
- if (tracker.shouldPushSessions(agentName)) {
14070
- const sessionsSnapshot = tracker.getSessionsSnapshot(agentName);
14022
+ tracker2.addSession(agentName, sessionKey);
14023
+ if (tracker2.shouldPushSessions(agentName)) {
14024
+ const sessionsSnapshot = tracker2.getSessionsSnapshot(agentName);
14071
14025
  await pushSessions(cfg.apiKey, agentName, sessionsSnapshot);
14072
- tracker.markSessionsPushed(agentName);
14026
+ tracker2.markSessionsPushed(agentName);
14073
14027
  }
14074
14028
  const parsed = parseSessionKey(sessionKey);
14075
14029
  const entry = buildActivityEntry(agentName, "session_start", {
@@ -14077,7 +14031,7 @@ Do not attempt to make more comments until ${resetAt}.`
14077
14031
  sessionKey,
14078
14032
  resumedFrom: event.resumedFrom
14079
14033
  });
14080
- if (entry) addActivityToHot(entry);
14034
+ if (entry) activityBatch.add(entry);
14081
14035
  } catch (err) {
14082
14036
  logger.warn(`cohort-sync: session_start tracking failed: ${String(err)}`);
14083
14037
  }
@@ -14088,18 +14042,18 @@ Do not attempt to make more comments until ${resetAt}.`
14088
14042
  const agentName = resolveAgentName(agentId);
14089
14043
  try {
14090
14044
  const sessionKey = ctx.sessionId ?? "";
14091
- tracker.removeSession(agentName, sessionKey);
14092
- if (tracker.shouldPushSessions(agentName)) {
14093
- const sessionsSnapshot = tracker.getSessionsSnapshot(agentName);
14045
+ tracker2.removeSession(agentName, sessionKey);
14046
+ if (tracker2.shouldPushSessions(agentName)) {
14047
+ const sessionsSnapshot = tracker2.getSessionsSnapshot(agentName);
14094
14048
  await pushSessions(cfg.apiKey, agentName, sessionsSnapshot);
14095
- tracker.markSessionsPushed(agentName);
14049
+ tracker2.markSessionsPushed(agentName);
14096
14050
  }
14097
14051
  const entry = buildActivityEntry(agentName, "session_end", {
14098
14052
  sessionKey,
14099
14053
  messageCount: event.messageCount,
14100
14054
  durationMs: event.durationMs
14101
14055
  });
14102
- if (entry) addActivityToHot(entry);
14056
+ if (entry) activityBatch.add(entry);
14103
14057
  } catch (err) {
14104
14058
  logger.warn(`cohort-sync: session_end tracking failed: ${String(err)}`);
14105
14059
  }
@@ -14116,7 +14070,7 @@ Do not attempt to make more comments until ${resetAt}.`
14116
14070
  sessionKey: ctx.sessionKey,
14117
14071
  model: resolveModel(ctx.agentId ?? "main")
14118
14072
  });
14119
- if (entry) addActivityToHot(entry);
14073
+ if (entry) activityBatch.add(entry);
14120
14074
  } catch (err) {
14121
14075
  logger.warn(`cohort-sync: after_tool_call activity failed: ${String(err)}`);
14122
14076
  }
@@ -14125,7 +14079,7 @@ Do not attempt to make more comments until ${resetAt}.`
14125
14079
  diag("HOOK_message_received_RAW", {
14126
14080
  ctx: dumpCtx(ctx),
14127
14081
  event: dumpEvent(_event),
14128
- bridgeStateBefore: { ...getHotState().channelAgentBridge }
14082
+ bridgeStateBefore: Object.fromEntries(getChannelAgentBridge())
14129
14083
  });
14130
14084
  const agentName = resolveAgentFromContext(ctx);
14131
14085
  const channel = ctx.channelId;
@@ -14140,7 +14094,7 @@ Do not attempt to make more comments until ${resetAt}.`
14140
14094
  const entry = buildActivityEntry(agentName, "message_received", {
14141
14095
  channel: channel ?? "unknown"
14142
14096
  });
14143
- if (entry) addActivityToHot(entry);
14097
+ if (entry) activityBatch.add(entry);
14144
14098
  } catch (err) {
14145
14099
  logger.warn(`cohort-sync: message_received activity failed: ${String(err)}`);
14146
14100
  }
@@ -14149,7 +14103,7 @@ Do not attempt to make more comments until ${resetAt}.`
14149
14103
  diag("HOOK_message_sent_RAW", {
14150
14104
  ctx: dumpCtx(ctx),
14151
14105
  event: dumpEvent(event),
14152
- bridgeStateBefore: { ...getHotState().channelAgentBridge }
14106
+ bridgeStateBefore: Object.fromEntries(getChannelAgentBridge())
14153
14107
  });
14154
14108
  const agentName = resolveAgentFromContext(ctx);
14155
14109
  const channel = ctx.channelId;
@@ -14168,7 +14122,7 @@ Do not attempt to make more comments until ${resetAt}.`
14168
14122
  success: event.success,
14169
14123
  error: event.error
14170
14124
  });
14171
- if (entry) addActivityToHot(entry);
14125
+ if (entry) activityBatch.add(entry);
14172
14126
  } catch (err) {
14173
14127
  logger.warn(`cohort-sync: message_sent activity failed: ${String(err)}`);
14174
14128
  }
@@ -14181,7 +14135,7 @@ Do not attempt to make more comments until ${resetAt}.`
14181
14135
  const entry = buildActivityEntry(agentName, "before_compaction", {
14182
14136
  sessionKey: ctx.sessionKey
14183
14137
  });
14184
- if (entry) addActivityToHot(entry);
14138
+ if (entry) activityBatch.add(entry);
14185
14139
  } catch (err) {
14186
14140
  logger.warn(`cohort-sync: before_compaction activity failed: ${String(err)}`);
14187
14141
  }
@@ -14195,25 +14149,24 @@ Do not attempt to make more comments until ${resetAt}.`
14195
14149
  reason: event.reason,
14196
14150
  sessionKey: ctx.sessionKey
14197
14151
  });
14198
- if (entry) addActivityToHot(entry);
14152
+ if (entry) activityBatch.add(entry);
14199
14153
  } catch (err) {
14200
14154
  logger.warn(`cohort-sync: before_reset activity failed: ${String(err)}`);
14201
14155
  }
14202
14156
  });
14203
14157
  api.on("gateway_stop", async () => {
14204
- diag("HOOK_gateway_stop", { bridgeState: { ...getHotState().channelAgentBridge } });
14205
- clearIntervalsFromHot();
14206
- heartbeatInterval = null;
14207
- activityFlushInterval = null;
14208
- await flushActivityBuffer().catch((err) => {
14209
- logger.warn(`cohort-sync: final activity flush failed: ${String(err)}`);
14210
- });
14158
+ diag("HOOK_gateway_stop", { bridgeState: Object.fromEntries(getChannelAgentBridge()) });
14159
+ if (keepaliveInterval) {
14160
+ clearInterval(keepaliveInterval);
14161
+ keepaliveInterval = null;
14162
+ }
14163
+ activityBatch.drain();
14211
14164
  const allAgentIds = ["main", ...(config?.agents?.list ?? []).map((a) => a.id)];
14212
14165
  for (const agentId of allAgentIds) {
14213
14166
  const agentName = resolveAgentName(agentId);
14214
14167
  try {
14215
- tracker.updateStatus(agentName, "unreachable");
14216
- const snapshot = tracker.getTelemetrySnapshot(agentName);
14168
+ tracker2.updateStatus(agentName, "unreachable");
14169
+ const snapshot = tracker2.getTelemetrySnapshot(agentName);
14217
14170
  if (snapshot) {
14218
14171
  await pushTelemetry(cfg.apiKey, { ...snapshot, pluginVersion: PLUGIN_VERSION });
14219
14172
  }
@@ -14221,15 +14174,20 @@ Do not attempt to make more comments until ${resetAt}.`
14221
14174
  logger.warn(`cohort-sync: final unreachable push failed for ${agentName}: ${String(err)}`);
14222
14175
  }
14223
14176
  }
14224
- saveSessionsToDisk(tracker);
14177
+ saveSessionsToDisk(tracker2);
14178
+ if (persistentGwClient) {
14179
+ persistentGwClient.close();
14180
+ persistentGwClient = null;
14181
+ gwClientInitialized = false;
14182
+ }
14225
14183
  try {
14226
14184
  await markAllUnreachable(cfg, logger);
14227
14185
  } catch (err) {
14228
14186
  logger.warn(`cohort-sync: markAllUnreachable failed: ${String(err)}`);
14229
14187
  }
14230
- tracker.clear();
14231
- closeSubscription();
14232
- logger.info("cohort-sync: subscription closed");
14188
+ tracker2.clear();
14189
+ closeBridge();
14190
+ logger.info("cohort-sync: gateway stopped, all resources cleaned up");
14233
14191
  });
14234
14192
  }
14235
14193
 
@@ -14435,12 +14393,11 @@ var plugin = {
14435
14393
  apiUrl,
14436
14394
  apiKey,
14437
14395
  stateDir: svcCtx.stateDir,
14438
- agentNameMap: cfg?.agentNameMap,
14439
- syncIntervalMs: cfg?.syncIntervalMs
14396
+ agentNameMap: cfg?.agentNameMap
14440
14397
  });
14441
14398
  },
14442
14399
  async stop() {
14443
- closeSubscription();
14400
+ closeBridge();
14444
14401
  api.logger.info("cohort-sync: service stopped");
14445
14402
  }
14446
14403
  });