@cfio/cohort-sync 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -16,6 +16,14 @@ __export(keychain_exports, {
16
16
  setCredential: () => setCredential
17
17
  });
18
18
  import { execFile } from "node:child_process";
19
+ import os3 from "node:os";
20
+ function assertMacOS(operation) {
21
+ if (os3.platform() !== "darwin") {
22
+ throw new Error(
23
+ `cohort-sync: ${operation} requires macOS Keychain. On Linux/Windows, set your API key in OpenClaw config: plugins.entries.cohort-sync.config.apiKey`
24
+ );
25
+ }
26
+ }
19
27
  function securityCmd(args) {
20
28
  return new Promise((resolve, reject) => {
21
29
  execFile("security", args, { timeout: 5e3 }, (err, stdout, stderr) => {
@@ -37,6 +45,7 @@ function isNotFoundError(err) {
37
45
  return false;
38
46
  }
39
47
  async function setCredential(apiUrl, apiKey) {
48
+ assertMacOS("storing credentials");
40
49
  await securityCmd([
41
50
  "add-generic-password",
42
51
  "-s",
@@ -49,6 +58,7 @@ async function setCredential(apiUrl, apiKey) {
49
58
  ]);
50
59
  }
51
60
  async function getCredential(apiUrl) {
61
+ assertMacOS("reading credentials");
52
62
  try {
53
63
  const { stdout } = await securityCmd([
54
64
  "find-generic-password",
@@ -65,6 +75,7 @@ async function getCredential(apiUrl) {
65
75
  }
66
76
  }
67
77
  async function deleteCredential(apiUrl) {
78
+ assertMacOS("deleting credentials");
68
79
  try {
69
80
  await securityCmd([
70
81
  "delete-generic-password",
@@ -88,8 +99,8 @@ var init_keychain = __esm({
88
99
  });
89
100
 
90
101
  // src/hooks.ts
91
- import fs from "node:fs";
92
- import path from "node:path";
102
+ import fs3 from "node:fs";
103
+ import path3 from "node:path";
93
104
 
94
105
  // ../../node_modules/.pnpm/@sinclair+typebox@0.34.48/node_modules/@sinclair/typebox/build/esm/type/guard/value.mjs
95
106
  var value_exports = {};
@@ -4554,12 +4565,12 @@ function createApi(pathParts = []) {
4554
4565
  `API path is expected to be of the form \`api.moduleName.functionName\`. Found: \`${found}\``
4555
4566
  );
4556
4567
  }
4557
- const path2 = pathParts.slice(0, -1).join("/");
4568
+ const path4 = pathParts.slice(0, -1).join("/");
4558
4569
  const exportName = pathParts[pathParts.length - 1];
4559
4570
  if (exportName === "default") {
4560
- return path2;
4571
+ return path4;
4561
4572
  } else {
4562
- return path2 + ":" + exportName;
4573
+ return path4 + ":" + exportName;
4563
4574
  }
4564
4575
  } else if (prop === Symbol.toStringTag) {
4565
4576
  return "FunctionReference";
@@ -7628,16 +7639,16 @@ var require_constants = __commonJS({
7628
7639
  });
7629
7640
  var require_node_gyp_build = __commonJS({
7630
7641
  "../common/temp/node_modules/.pnpm/node-gyp-build@4.8.4/node_modules/node-gyp-build/node-gyp-build.js"(exports, module) {
7631
- var fs2 = __require("fs");
7632
- var path2 = __require("path");
7633
- var os = __require("os");
7642
+ var fs4 = __require("fs");
7643
+ var path4 = __require("path");
7644
+ var os4 = __require("os");
7634
7645
  var runtimeRequire = typeof __webpack_require__ === "function" ? __non_webpack_require__ : __require;
7635
7646
  var vars = process.config && process.config.variables || {};
7636
7647
  var prebuildsOnly = !!process.env.PREBUILDS_ONLY;
7637
7648
  var abi = process.versions.modules;
7638
7649
  var runtime = isElectron() ? "electron" : isNwjs() ? "node-webkit" : "node";
7639
- var arch = process.env.npm_config_arch || os.arch();
7640
- var platform = process.env.npm_config_platform || os.platform();
7650
+ var arch = process.env.npm_config_arch || os4.arch();
7651
+ var platform = process.env.npm_config_platform || os4.platform();
7641
7652
  var libc = process.env.LIBC || (isAlpine(platform) ? "musl" : "glibc");
7642
7653
  var armv = process.env.ARM_VERSION || (arch === "arm64" ? "8" : vars.arm_version) || "";
7643
7654
  var uv = (process.versions.uv || "").split(".")[0];
@@ -7646,21 +7657,21 @@ var require_node_gyp_build = __commonJS({
7646
7657
  return runtimeRequire(load.resolve(dir));
7647
7658
  }
7648
7659
  load.resolve = load.path = function(dir) {
7649
- dir = path2.resolve(dir || ".");
7660
+ dir = path4.resolve(dir || ".");
7650
7661
  try {
7651
- var name = runtimeRequire(path2.join(dir, "package.json")).name.toUpperCase().replace(/-/g, "_");
7662
+ var name = runtimeRequire(path4.join(dir, "package.json")).name.toUpperCase().replace(/-/g, "_");
7652
7663
  if (process.env[name + "_PREBUILD"]) dir = process.env[name + "_PREBUILD"];
7653
7664
  } catch (err) {
7654
7665
  }
7655
7666
  if (!prebuildsOnly) {
7656
- var release = getFirst(path2.join(dir, "build/Release"), matchBuild);
7667
+ var release = getFirst(path4.join(dir, "build/Release"), matchBuild);
7657
7668
  if (release) return release;
7658
- var debug = getFirst(path2.join(dir, "build/Debug"), matchBuild);
7669
+ var debug = getFirst(path4.join(dir, "build/Debug"), matchBuild);
7659
7670
  if (debug) return debug;
7660
7671
  }
7661
7672
  var prebuild = resolve(dir);
7662
7673
  if (prebuild) return prebuild;
7663
- var nearby = resolve(path2.dirname(process.execPath));
7674
+ var nearby = resolve(path4.dirname(process.execPath));
7664
7675
  if (nearby) return nearby;
7665
7676
  var target = [
7666
7677
  "platform=" + platform,
@@ -7677,26 +7688,26 @@ var require_node_gyp_build = __commonJS({
7677
7688
  ].filter(Boolean).join(" ");
7678
7689
  throw new Error("No native build was found for " + target + "\n loaded from: " + dir + "\n");
7679
7690
  function resolve(dir2) {
7680
- var tuples = readdirSync(path2.join(dir2, "prebuilds")).map(parseTuple);
7691
+ var tuples = readdirSync(path4.join(dir2, "prebuilds")).map(parseTuple);
7681
7692
  var tuple = tuples.filter(matchTuple(platform, arch)).sort(compareTuples)[0];
7682
7693
  if (!tuple) return;
7683
- var prebuilds = path2.join(dir2, "prebuilds", tuple.name);
7694
+ var prebuilds = path4.join(dir2, "prebuilds", tuple.name);
7684
7695
  var parsed = readdirSync(prebuilds).map(parseTags);
7685
7696
  var candidates = parsed.filter(matchTags(runtime, abi));
7686
7697
  var winner = candidates.sort(compareTags(runtime))[0];
7687
- if (winner) return path2.join(prebuilds, winner.file);
7698
+ if (winner) return path4.join(prebuilds, winner.file);
7688
7699
  }
7689
7700
  };
7690
7701
  function readdirSync(dir) {
7691
7702
  try {
7692
- return fs2.readdirSync(dir);
7703
+ return fs4.readdirSync(dir);
7693
7704
  } catch (err) {
7694
7705
  return [];
7695
7706
  }
7696
7707
  }
7697
7708
  function getFirst(dir, filter) {
7698
7709
  var files = readdirSync(dir).filter(filter);
7699
- return files[0] && path2.join(dir, files[0]);
7710
+ return files[0] && path4.join(dir, files[0]);
7700
7711
  }
7701
7712
  function matchBuild(name) {
7702
7713
  return /\.node$/.test(name);
@@ -7783,7 +7794,7 @@ var require_node_gyp_build = __commonJS({
7783
7794
  return typeof window !== "undefined" && window.process && window.process.type === "renderer";
7784
7795
  }
7785
7796
  function isAlpine(platform2) {
7786
- return platform2 === "linux" && fs2.existsSync("/etc/alpine-release");
7797
+ return platform2 === "linux" && fs4.existsSync("/etc/alpine-release");
7787
7798
  }
7788
7799
  load.parseTags = parseTags;
7789
7800
  load.matchTags = matchTags;
@@ -11502,110 +11513,6 @@ var _systemSchema = defineSchema({
11502
11513
  })
11503
11514
  });
11504
11515
 
11505
- // src/gateway-rpc.ts
11506
- import crypto from "node:crypto";
11507
- function buildRequestFrame(id, method, params) {
11508
- return { id, type: "req", method, params };
11509
- }
11510
- function getPendingRequests() {
11511
- const g = globalThis;
11512
- const hot = g.__cohort_sync__ ?? (g.__cohort_sync__ = {});
11513
- if (!hot.pendingGatewayRequests) hot.pendingGatewayRequests = /* @__PURE__ */ new Map();
11514
- return hot.pendingGatewayRequests;
11515
- }
11516
- var authReady = null;
11517
- function openGatewayConnection(port, token, logger) {
11518
- const ws = new WebSocket(`ws://127.0.0.1:${port}`);
11519
- let resolveAuth;
11520
- let rejectAuth;
11521
- let authSettled = false;
11522
- authReady = new Promise((res, rej) => {
11523
- resolveAuth = res;
11524
- rejectAuth = rej;
11525
- });
11526
- ws.addEventListener("open", () => {
11527
- ws.send(JSON.stringify({
11528
- minProtocol: 1,
11529
- maxProtocol: 1,
11530
- client: {
11531
- id: "gateway-client",
11532
- version: "1.0.0",
11533
- platform: process.platform,
11534
- mode: "backend"
11535
- },
11536
- auth: { token }
11537
- }));
11538
- logger.info(`cohort-sync: gateway WS connected to port ${port}, awaiting hello-ok`);
11539
- });
11540
- ws.addEventListener("message", (event) => {
11541
- try {
11542
- const data = JSON.parse(String(event.data));
11543
- if (data.type === "hello-ok") {
11544
- authSettled = true;
11545
- resolveAuth();
11546
- logger.info("cohort-sync: gateway WS authenticated");
11547
- return;
11548
- }
11549
- if (data.type === "hello-error" || data.type === "error") {
11550
- authSettled = true;
11551
- rejectAuth(new Error(`Gateway auth failed: ${data.message ?? data.error ?? "unknown"}`));
11552
- ws.close();
11553
- return;
11554
- }
11555
- const pending = getPendingRequests();
11556
- if (data.type === "res" && data.id && pending.has(data.id)) {
11557
- const entry = pending.get(data.id);
11558
- pending.delete(data.id);
11559
- clearTimeout(entry.timer);
11560
- if (data.ok) {
11561
- entry.resolve(data.payload);
11562
- } else {
11563
- entry.reject(new Error(`Gateway method failed: ${data.error?.message ?? "unknown"}`));
11564
- }
11565
- }
11566
- } catch {
11567
- }
11568
- });
11569
- ws.addEventListener("close", () => {
11570
- logger.warn("cohort-sync: gateway WS closed");
11571
- if (!authSettled) {
11572
- authSettled = true;
11573
- rejectAuth(new Error("Gateway WS closed during auth"));
11574
- }
11575
- const pending = getPendingRequests();
11576
- for (const [, { reject, timer }] of pending) {
11577
- clearTimeout(timer);
11578
- reject(new Error("Gateway WS closed"));
11579
- }
11580
- pending.clear();
11581
- });
11582
- ws.addEventListener("error", (err) => {
11583
- logger.error(`cohort-sync: gateway WS error: ${String(err)}`);
11584
- });
11585
- return ws;
11586
- }
11587
- async function callGatewayMethod(ws, method, params, timeoutMs = 1e4) {
11588
- if (!ws || ws.readyState !== WebSocket.OPEN) {
11589
- throw new Error("Gateway WS not connected");
11590
- }
11591
- if (authReady) await authReady;
11592
- const id = crypto.randomUUID();
11593
- const frame = buildRequestFrame(id, method, params);
11594
- const pending = getPendingRequests();
11595
- return new Promise((resolve, reject) => {
11596
- const timer = setTimeout(() => {
11597
- pending.delete(id);
11598
- reject(new Error(`Gateway method ${method} timed out after ${timeoutMs}ms`));
11599
- }, timeoutMs);
11600
- pending.set(id, {
11601
- resolve,
11602
- reject,
11603
- timer
11604
- });
11605
- ws.send(JSON.stringify(frame));
11606
- });
11607
- }
11608
-
11609
11516
  // src/cron-mapping.ts
11610
11517
  function formatSchedule(s) {
11611
11518
  switch (s.kind) {
@@ -11731,6 +11638,7 @@ var pushActivityFromPluginRef = makeFunctionReference("activityFeed:pushActivity
11731
11638
  var upsertCronSnapshotFromPluginRef = makeFunctionReference("telemetryPlugin:upsertCronSnapshotFromPlugin");
11732
11639
  var getPendingCommandsForPlugin = makeFunctionReference("gatewayCommands:getPendingForPlugin");
11733
11640
  var acknowledgeCommandRef = makeFunctionReference("gatewayCommands:acknowledgeCommand");
11641
+ var failCommandRef = makeFunctionReference("gatewayCommands:failCommand");
11734
11642
  var addCommentFromPluginRef = makeFunctionReference("comments:addCommentFromPlugin");
11735
11643
  var HOT_KEY = "__cohort_sync__";
11736
11644
  function getHotState() {
@@ -11745,10 +11653,11 @@ function getHotState() {
11745
11653
  intervals: { heartbeat: null, activityFlush: null },
11746
11654
  activityBuffer: [],
11747
11655
  channelAgentBridge: {},
11748
- gatewayWs: null,
11749
11656
  gatewayPort: null,
11750
11657
  gatewayToken: null,
11751
- commandSubscription: null
11658
+ gatewayProtocolClient: null,
11659
+ commandSubscription: null,
11660
+ cronRunNowPoll: null
11752
11661
  };
11753
11662
  globalThis[HOT_KEY] = state;
11754
11663
  }
@@ -11793,6 +11702,13 @@ function restoreFromHotReload(logger) {
11793
11702
  client = state.client;
11794
11703
  savedConvexUrl = state.convexUrl;
11795
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");
11796
11712
  }
11797
11713
  if (unsubscribers.length === 0 && state.unsubscribers.length > 0) {
11798
11714
  unsubscribers.push(...state.unsubscribers);
@@ -11904,98 +11820,132 @@ function initCommandSubscription(cfg, logger, resolveAgentName) {
11904
11820
  return;
11905
11821
  }
11906
11822
  if (cmd.type.startsWith("cron")) {
11907
- const ws = getHotState().gatewayWs;
11908
- if (!ws || ws.readyState !== WebSocket.OPEN) {
11909
- logger.warn(`cohort-sync: gateway WS not connected, cannot execute ${cmd.type}`);
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}`);
11910
11828
  continue;
11911
11829
  }
11912
- const nameMap = cfg.agentNameMap ?? {};
11913
- if (cmd.type === "cronEnable") {
11914
- await callGatewayMethod(ws, "cohort-sync/cron-update", {
11915
- jobId: cmd.payload?.jobId,
11916
- patch: { enabled: true }
11917
- });
11918
- } else if (cmd.type === "cronDisable") {
11919
- await callGatewayMethod(ws, "cohort-sync/cron-update", {
11920
- jobId: cmd.payload?.jobId,
11921
- patch: { enabled: false }
11922
- });
11923
- } else if (cmd.type === "cronDelete") {
11924
- await callGatewayMethod(ws, "cohort-sync/cron-remove", {
11925
- jobId: cmd.payload?.jobId
11926
- });
11927
- } else if (cmd.type === "cronRunNow") {
11928
- const runResult = await callGatewayMethod(
11929
- ws,
11930
- "cohort-sync/cron-run",
11931
- { jobId: cmd.payload?.jobId }
11932
- );
11933
- if (runResult?.ok && runResult?.ran) {
11934
- const jobId = cmd.payload?.jobId;
11935
- let polls = 0;
11936
- const pollInterval = setInterval(async () => {
11937
- polls++;
11938
- if (polls >= 15) {
11939
- clearInterval(pollInterval);
11940
- return;
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;
11941
11889
  }
11942
- try {
11943
- const pollWs = getHotState().gatewayWs;
11944
- if (!pollWs || pollWs.readyState !== WebSocket.OPEN) {
11945
- clearInterval(pollInterval);
11946
- return;
11947
- }
11948
- const freshJobs = await callGatewayMethod(pollWs, "cohort-sync/cron-list");
11949
- const job = freshJobs.find((j) => j.id === jobId);
11950
- if (job && !job.state?.runningAtMs) {
11951
- clearInterval(pollInterval);
11952
- const mapped = freshJobs.map((j) => mapCronJob(j, resolveAgentName));
11953
- await pushCronSnapshot(cfg.apiKey, mapped);
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"
11954
11903
  }
11955
- } catch {
11956
- }
11957
- }, 2e3);
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}`);
11958
11921
  }
11959
- } else if (cmd.type === "cronCreate") {
11960
- const agentId = reverseResolveAgentName(cmd.payload?.agentId ?? "main", nameMap);
11961
- await callGatewayMethod(ws, "cohort-sync/cron-add", {
11962
- job: {
11963
- agentId,
11964
- name: cmd.payload?.name,
11965
- enabled: true,
11966
- schedule: cmd.payload?.schedule,
11967
- payload: { kind: "agentTurn", message: cmd.payload?.message },
11968
- sessionTarget: "isolated",
11969
- wakeMode: "now"
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)}`);
11970
11930
  }
11971
- });
11972
- } else if (cmd.type === "cronUpdate") {
11973
- const patch = {};
11974
- if (cmd.payload?.name) patch.name = cmd.payload.name;
11975
- if (cmd.payload?.schedule) patch.schedule = cmd.payload.schedule;
11976
- if (cmd.payload?.message) patch.payload = { kind: "agentTurn", message: cmd.payload.message };
11977
- if (cmd.payload?.agentId) patch.agentId = reverseResolveAgentName(cmd.payload.agentId, nameMap);
11978
- await callGatewayMethod(ws, "cohort-sync/cron-update", {
11979
- jobId: cmd.payload?.jobId,
11980
- patch
11981
- });
11982
- } else {
11983
- logger.warn(`cohort-sync: unknown cron command type: ${cmd.type}`);
11984
- }
11985
- if (ws.readyState === WebSocket.OPEN) {
11986
- try {
11987
- const freshJobs = await callGatewayMethod(ws, "cohort-sync/cron-list");
11988
- const mapped = freshJobs.map((j) => mapCronJob(j, resolveAgentName));
11989
- await pushCronSnapshot(cfg.apiKey, mapped);
11990
- } catch (snapErr) {
11991
- logger.warn(`cohort-sync: post-command snapshot push failed: ${String(snapErr)}`);
11992
11931
  }
11932
+ } finally {
11933
+ gwClient.close();
11993
11934
  }
11994
11935
  } else {
11995
11936
  logger.warn(`cohort-sync: unknown command type: ${cmd.type}`);
11996
11937
  }
11997
11938
  } catch (err) {
11998
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
+ }
11999
11949
  }
12000
11950
  }
12001
11951
  } finally {
@@ -12044,6 +11994,13 @@ function closeSubscription() {
12044
11994
  }
12045
11995
  state.commandSubscription = null;
12046
11996
  }
11997
+ if (state.gatewayProtocolClient) {
11998
+ try {
11999
+ state.gatewayProtocolClient.close();
12000
+ } catch {
12001
+ }
12002
+ state.gatewayProtocolClient = null;
12003
+ }
12047
12004
  client?.close();
12048
12005
  client = null;
12049
12006
  clearHotState();
@@ -12093,7 +12050,9 @@ function clearIntervalsFromHot() {
12093
12050
  const state = getHotState();
12094
12051
  if (state.intervals.heartbeat) clearInterval(state.intervals.heartbeat);
12095
12052
  if (state.intervals.activityFlush) clearInterval(state.intervals.activityFlush);
12053
+ if (state.cronRunNowPoll) clearInterval(state.cronRunNowPoll);
12096
12054
  state.intervals = { heartbeat: null, activityFlush: null };
12055
+ state.cronRunNowPoll = null;
12097
12056
  }
12098
12057
  function addActivityToHot(entry) {
12099
12058
  const state = getHotState();
@@ -12152,31 +12111,31 @@ var VALID_STATUSES = /* @__PURE__ */ new Set(["idle", "working", "waiting"]);
12152
12111
  function normalizeStatus(status) {
12153
12112
  return VALID_STATUSES.has(status) ? status : "idle";
12154
12113
  }
12155
- async function v1Get(apiUrl, apiKey, path2) {
12156
- const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path2}`, {
12114
+ async function v1Get(apiUrl, apiKey, path4) {
12115
+ const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
12157
12116
  headers: { Authorization: `Bearer ${apiKey}` },
12158
12117
  signal: AbortSignal.timeout(1e4)
12159
12118
  });
12160
- if (!res.ok) throw new Error(`GET ${path2} \u2192 ${res.status}`);
12119
+ if (!res.ok) throw new Error(`GET ${path4} \u2192 ${res.status}`);
12161
12120
  return res.json();
12162
12121
  }
12163
- async function v1Patch(apiUrl, apiKey, path2, body) {
12164
- const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path2}`, {
12122
+ async function v1Patch(apiUrl, apiKey, path4, body) {
12123
+ const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
12165
12124
  method: "PATCH",
12166
12125
  headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
12167
12126
  body: JSON.stringify(body),
12168
12127
  signal: AbortSignal.timeout(1e4)
12169
12128
  });
12170
- if (!res.ok) throw new Error(`PATCH ${path2} \u2192 ${res.status}`);
12129
+ if (!res.ok) throw new Error(`PATCH ${path4} \u2192 ${res.status}`);
12171
12130
  }
12172
- async function v1Post(apiUrl, apiKey, path2, body) {
12173
- const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path2}`, {
12131
+ async function v1Post(apiUrl, apiKey, path4, body) {
12132
+ const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
12174
12133
  method: "POST",
12175
12134
  headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
12176
12135
  body: JSON.stringify(body),
12177
12136
  signal: AbortSignal.timeout(1e4)
12178
12137
  });
12179
- if (!res.ok) throw new Error(`POST ${path2} \u2192 ${res.status}`);
12138
+ if (!res.ok) throw new Error(`POST ${path4} \u2192 ${res.status}`);
12180
12139
  }
12181
12140
  async function checkForUpdate(currentVersion, logger) {
12182
12141
  try {
@@ -12344,64 +12303,542 @@ async function fullSync(agentName, model, cfg, logger, openClawAgents) {
12344
12303
  logger.info("cohort-sync: full sync complete");
12345
12304
  }
12346
12305
 
12347
- // src/gateway-methods.ts
12348
- function registerCronGatewayMethods(api) {
12349
- api.registerGatewayMethod("cohort-sync/cron-list", async ({ context, respond }) => {
12350
- try {
12351
- const jobs = await context.cron.list({ includeDisabled: true });
12352
- respond(true, jobs);
12353
- } catch (err) {
12354
- respond(false, void 0, {
12355
- code: "INTERNAL_ERROR",
12356
- message: (err instanceof Error ? err.message : String(err)).slice(0, 200)
12357
- });
12306
+ // src/gateway-client.ts
12307
+ import crypto2 from "node:crypto";
12308
+
12309
+ // src/diag.ts
12310
+ import fs from "node:fs";
12311
+ import path from "node:path";
12312
+ import os from "node:os";
12313
+ var LOG_DIR = path.join(os.homedir(), ".openclaw", "logs", "cohort-sync");
12314
+ var LOG_PATH = path.join(LOG_DIR, "diag.log");
12315
+ var LOG_PATH_ROTATED = path.join(LOG_DIR, "diag.log.1");
12316
+ var MAX_LOG_SIZE = 5 * 1024 * 1024;
12317
+ var isDebug = process.env.COHORT_SYNC_DEBUG === "1";
12318
+ try {
12319
+ fs.mkdirSync(LOG_DIR, { recursive: true, mode: 448 });
12320
+ } catch {
12321
+ }
12322
+ var writesSinceCheck = 0;
12323
+ function diag(label, data) {
12324
+ if (!isDebug && !label.startsWith("HOOK_") && !label.startsWith("MODULE_") && !label.startsWith("REGISTER_") && !label.startsWith("GW_WS_AUTH") && !label.startsWith("GW_WS_CLOSED") && !label.startsWith("GW_WS_ERROR") && !label.startsWith("GW_CLIENT_") && !label.startsWith("HEARTBEAT_CRON")) {
12325
+ return;
12326
+ }
12327
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
12328
+ const sanitized = data ? " " + JSON.stringify(data, (_k, v2) => {
12329
+ if (typeof v2 === "string" && v2.length > 200) return v2.slice(0, 200) + "\u2026";
12330
+ return v2;
12331
+ }) : "";
12332
+ const line = `[${ts}] ${label}${sanitized}
12333
+ `;
12334
+ try {
12335
+ fs.appendFileSync(LOG_PATH, line, { mode: 384 });
12336
+ if (++writesSinceCheck >= 100) {
12337
+ writesSinceCheck = 0;
12338
+ const stat = fs.statSync(LOG_PATH);
12339
+ if (stat.size > MAX_LOG_SIZE) {
12340
+ try {
12341
+ fs.unlinkSync(LOG_PATH_ROTATED);
12342
+ } catch {
12343
+ }
12344
+ fs.renameSync(LOG_PATH, LOG_PATH_ROTATED);
12345
+ }
12358
12346
  }
12359
- });
12360
- api.registerGatewayMethod("cohort-sync/cron-run", async ({ context, params, respond }) => {
12361
- try {
12362
- const result = await context.cron.run(params.jobId, "force");
12363
- respond(true, result);
12364
- } catch (err) {
12365
- respond(false, void 0, {
12366
- code: "CRON_RUN_FAILED",
12367
- message: (err instanceof Error ? err.message : String(err)).slice(0, 200)
12368
- });
12347
+ } catch {
12348
+ }
12349
+ }
12350
+
12351
+ // src/device-identity-crypto.ts
12352
+ import crypto from "node:crypto";
12353
+ import fs2 from "node:fs";
12354
+ import path2 from "node:path";
12355
+ import os2 from "node:os";
12356
+ var IDENTITY_PATH = path2.join(os2.homedir(), ".openclaw", "extensions", "cohort-sync", ".device-identity.json");
12357
+ function loadOrCreateDeviceIdentity() {
12358
+ try {
12359
+ const data = JSON.parse(fs2.readFileSync(IDENTITY_PATH, "utf-8"));
12360
+ if (data.deviceId && data.publicKeyPem && data.privateKeyPem) {
12361
+ diag("GW_CLIENT_DEVICE_IDENTITY_LOADED", { deviceId: data.deviceId });
12362
+ return data;
12369
12363
  }
12364
+ } catch {
12365
+ }
12366
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
12367
+ const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
12368
+ const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
12369
+ const publicKeyDer = publicKey.export({ type: "spki", format: "der" });
12370
+ const rawPublicKey = publicKeyDer.subarray(publicKeyDer.length - 32);
12371
+ const deviceId = crypto.createHash("sha256").update(rawPublicKey).digest("hex");
12372
+ const identity = { deviceId, publicKeyPem, privateKeyPem };
12373
+ try {
12374
+ fs2.writeFileSync(IDENTITY_PATH, JSON.stringify(identity, null, 2), { mode: 384 });
12375
+ diag("GW_CLIENT_DEVICE_IDENTITY_CREATED", { deviceId });
12376
+ } catch (err) {
12377
+ diag("GW_CLIENT_DEVICE_IDENTITY_WRITE_FAILED", { error: String(err) });
12378
+ }
12379
+ return identity;
12380
+ }
12381
+ function normalizeMetadata(value) {
12382
+ if (typeof value !== "string") return "";
12383
+ const trimmed = value.trim();
12384
+ if (!trimmed) return "";
12385
+ return trimmed.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
12386
+ }
12387
+ function buildDeviceAuthPayloadV3(params) {
12388
+ return [
12389
+ "v3",
12390
+ params.deviceId,
12391
+ params.clientId,
12392
+ params.clientMode,
12393
+ params.role,
12394
+ params.scopes.join(","),
12395
+ String(params.signedAtMs),
12396
+ params.token ?? "",
12397
+ params.nonce,
12398
+ normalizeMetadata(params.platform),
12399
+ normalizeMetadata(params.deviceFamily)
12400
+ ].join("|");
12401
+ }
12402
+ function signPayload(privateKeyPem, payload) {
12403
+ const key = crypto.createPrivateKey(privateKeyPem);
12404
+ const signature = crypto.sign(null, Buffer.from(payload, "utf-8"), key);
12405
+ return signature.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
12406
+ }
12407
+
12408
+ // src/gateway-client.ts
12409
+ var ALLOWED_METHODS = /* @__PURE__ */ new Set([
12410
+ "cron.list",
12411
+ "cron.add",
12412
+ "cron.update",
12413
+ "cron.remove",
12414
+ "cron.run",
12415
+ "cron.runs",
12416
+ "cron.status",
12417
+ "channels.status",
12418
+ "sessions.list",
12419
+ "sessions.preview",
12420
+ "agent",
12421
+ "snapshot",
12422
+ "system.presence"
12423
+ ]);
12424
+ function buildConnectFrame(id, token, pluginVersion, identity, nonce) {
12425
+ const signedAtMs = Date.now();
12426
+ const payload = buildDeviceAuthPayloadV3({
12427
+ deviceId: identity.deviceId,
12428
+ clientId: "gateway-client",
12429
+ clientMode: "backend",
12430
+ role: "operator",
12431
+ scopes: ["operator.read", "operator.write"],
12432
+ signedAtMs,
12433
+ token,
12434
+ nonce,
12435
+ platform: process.platform,
12436
+ deviceFamily: null
12370
12437
  });
12371
- api.registerGatewayMethod("cohort-sync/cron-update", async ({ context, params, respond }) => {
12372
- try {
12373
- const result = await context.cron.update(params.jobId, params.patch);
12374
- respond(true, result);
12375
- } catch (err) {
12376
- respond(false, void 0, {
12377
- code: "CRON_UPDATE_FAILED",
12378
- message: (err instanceof Error ? err.message : String(err)).slice(0, 200)
12438
+ const signature = signPayload(identity.privateKeyPem, payload);
12439
+ return {
12440
+ type: "req",
12441
+ id,
12442
+ method: "connect",
12443
+ params: {
12444
+ minProtocol: 3,
12445
+ maxProtocol: 3,
12446
+ client: {
12447
+ id: "gateway-client",
12448
+ version: pluginVersion,
12449
+ platform: process.platform,
12450
+ mode: "backend"
12451
+ },
12452
+ role: "operator",
12453
+ scopes: ["operator.read", "operator.write"],
12454
+ auth: { token },
12455
+ device: {
12456
+ id: identity.deviceId,
12457
+ publicKey: identity.publicKeyPem,
12458
+ signature,
12459
+ signedAt: signedAtMs,
12460
+ nonce
12461
+ },
12462
+ locale: "en-US",
12463
+ userAgent: `cohort-sync/${pluginVersion}`
12464
+ }
12465
+ };
12466
+ }
12467
+ function parseHelloOk(response) {
12468
+ if (!response.ok) {
12469
+ const msg = response.error?.message ?? "unknown error";
12470
+ throw new Error(`Gateway connect error: ${msg}`);
12471
+ }
12472
+ const payload = response.payload;
12473
+ if (payload?.type === "hello-error") {
12474
+ const msg = payload.message ?? "unknown error";
12475
+ throw new Error(`hello-error: ${msg}`);
12476
+ }
12477
+ if (payload?.type !== "hello-ok") {
12478
+ throw new Error(`Unexpected payload type: ${String(payload?.type ?? "missing")}`);
12479
+ }
12480
+ const policy = payload.policy ?? {};
12481
+ const methods = payload.methods ?? [];
12482
+ const events = payload.events ?? [];
12483
+ return {
12484
+ methods: new Set(methods),
12485
+ events: new Set(events),
12486
+ tickIntervalMs: policy.tickIntervalMs ?? 15e3,
12487
+ snapshot: payload.snapshot
12488
+ };
12489
+ }
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 {
12497
+ port;
12498
+ logger;
12499
+ ws = null;
12500
+ alive = false;
12501
+ tickWatchdog = null;
12502
+ reconnectTimer = null;
12503
+ reconnectAttempts = 0;
12504
+ closed = false;
12505
+ eventHandlers = /* @__PURE__ */ new Map();
12506
+ pluginVersion;
12507
+ tickIntervalMs = 15e3;
12508
+ // default; overwritten by hello-ok response
12509
+ deviceIdentity;
12510
+ /** Public Sets populated from hello-ok — consumers can inspect gateway capabilities */
12511
+ availableMethods = /* @__PURE__ */ new Set();
12512
+ availableEvents = /* @__PURE__ */ new Set();
12513
+ /**
12514
+ * @param port - Gateway WebSocket port
12515
+ * @param token - Auth token (stored in closure via constructor param, NOT on this or globalThis)
12516
+ * @param logger - RuntimeLogger from plugin SDK
12517
+ * @param pluginVersion - Version string for the connect frame userAgent
12518
+ */
12519
+ constructor(port, token, logger, pluginVersion = "0.5.0") {
12520
+ this.port = port;
12521
+ this.logger = logger;
12522
+ this.pluginVersion = pluginVersion;
12523
+ this.deviceIdentity = loadOrCreateDeviceIdentity();
12524
+ this._getToken = () => token;
12525
+ }
12526
+ /** Token accessor — closure over constructor param */
12527
+ _getToken;
12528
+ /**
12529
+ * Connect to the gateway, perform the protocol v3 handshake, and start
12530
+ * the tick watchdog.
12531
+ *
12532
+ * Flow:
12533
+ * 1. Open WebSocket to ws://127.0.0.1:{port}
12534
+ * 2. Wait up to 500ms for connect.challenge event (optional)
12535
+ * 3. Send connect request frame
12536
+ * 4. Wait for hello-ok response
12537
+ * 5. Start tick watchdog at 2.5x tickIntervalMs
12538
+ */
12539
+ async connect() {
12540
+ if (this.closed) throw new Error("GatewayClient has been closed");
12541
+ diag("GW_CLIENT_CONNECTING", { port: this.port });
12542
+ return new Promise((resolve, reject) => {
12543
+ const ws = new WebSocket(`ws://127.0.0.1:${this.port}`);
12544
+ this.ws = ws;
12545
+ let settled = false;
12546
+ const settle = (err) => {
12547
+ if (settled) return;
12548
+ settled = true;
12549
+ if (err) {
12550
+ this.alive = false;
12551
+ reject(err);
12552
+ } else {
12553
+ resolve();
12554
+ }
12555
+ };
12556
+ const handshakeTimeout = setTimeout(() => {
12557
+ settle(new Error("Gateway handshake timed out after 10000ms"));
12558
+ ws.close();
12559
+ }, 1e4);
12560
+ ws.addEventListener("open", () => {
12561
+ diag("GW_CLIENT_WS_OPEN", { port: this.port });
12562
+ let challengeReceived = false;
12563
+ let challengeNonce = "";
12564
+ const challengeTimer = setTimeout(() => {
12565
+ if (!challengeReceived) {
12566
+ diag("GW_CLIENT_NO_CHALLENGE", { waited: 500 });
12567
+ sendConnect();
12568
+ }
12569
+ }, 500);
12570
+ const onChallengeMessage = (event) => {
12571
+ try {
12572
+ const data = JSON.parse(String(event.data));
12573
+ if (data.type === "event" && data.event === "connect.challenge") {
12574
+ challengeReceived = true;
12575
+ clearTimeout(challengeTimer);
12576
+ challengeNonce = data.payload?.nonce ?? "";
12577
+ diag("GW_CLIENT_CHALLENGE_RECEIVED", { hasNonce: !!challengeNonce });
12578
+ sendConnect();
12579
+ }
12580
+ } catch {
12581
+ }
12582
+ };
12583
+ ws.addEventListener("message", onChallengeMessage);
12584
+ const sendConnect = () => {
12585
+ ws.removeEventListener("message", onChallengeMessage);
12586
+ const id = crypto2.randomUUID();
12587
+ const frame = buildConnectFrame(
12588
+ id,
12589
+ this._getToken(),
12590
+ this.pluginVersion,
12591
+ this.deviceIdentity,
12592
+ challengeNonce
12593
+ );
12594
+ diag("GW_CLIENT_SENDING_CONNECT", { id, protocol: 3 });
12595
+ ws.send(JSON.stringify(frame));
12596
+ const onHelloMessage = (event) => {
12597
+ try {
12598
+ const data = JSON.parse(String(event.data));
12599
+ if (data.type === "res" && data.id === id) {
12600
+ ws.removeEventListener("message", onHelloMessage);
12601
+ clearTimeout(handshakeTimeout);
12602
+ try {
12603
+ const result = parseHelloOk(data);
12604
+ this.availableMethods = result.methods;
12605
+ this.availableEvents = result.events;
12606
+ this.tickIntervalMs = result.tickIntervalMs;
12607
+ this.alive = true;
12608
+ this.reconnectAttempts = 0;
12609
+ diag("GW_CLIENT_HELLO_OK", {
12610
+ methods: result.methods.size,
12611
+ events: result.events.size,
12612
+ tickIntervalMs: result.tickIntervalMs
12613
+ });
12614
+ this.startTickWatchdog(result.tickIntervalMs);
12615
+ ws.addEventListener("message", (ev) => this.handleMessage(ev));
12616
+ if (result.snapshot) {
12617
+ this.emit("snapshot", result.snapshot);
12618
+ }
12619
+ this.logger.info("cohort-sync: gateway client connected (protocol v3)");
12620
+ settle();
12621
+ } catch (err) {
12622
+ diag("GW_CLIENT_HELLO_FAILED", { error: String(err) });
12623
+ settle(err instanceof Error ? err : new Error(String(err)));
12624
+ ws.close();
12625
+ }
12626
+ }
12627
+ } catch {
12628
+ }
12629
+ };
12630
+ ws.addEventListener("message", onHelloMessage);
12631
+ };
12632
+ });
12633
+ ws.addEventListener("close", () => {
12634
+ clearTimeout(handshakeTimeout);
12635
+ this.alive = false;
12636
+ this.stopTickWatchdog();
12637
+ diag("GW_CLIENT_WS_CLOSED", { port: this.port });
12638
+ this.logger.warn("cohort-sync: gateway client WebSocket closed");
12639
+ const pending = getPendingRequests();
12640
+ for (const [, entry] of pending) {
12641
+ clearTimeout(entry.timer);
12642
+ entry.reject(new Error("Gateway WebSocket closed"));
12643
+ }
12644
+ pending.clear();
12645
+ if (!settled) {
12646
+ settle(new Error("Gateway WebSocket closed during handshake"));
12647
+ }
12648
+ if (!this.closed) {
12649
+ this.scheduleReconnect();
12650
+ }
12379
12651
  });
12652
+ ws.addEventListener("error", (err) => {
12653
+ diag("GW_CLIENT_WS_ERROR", { error: String(err) });
12654
+ this.logger.error(`cohort-sync: gateway client WS error: ${String(err)}`);
12655
+ });
12656
+ });
12657
+ }
12658
+ /**
12659
+ * Whether the WebSocket is open and the handshake completed successfully.
12660
+ */
12661
+ isAlive() {
12662
+ return this.alive && this.ws !== null && this.ws.readyState === WebSocket.OPEN;
12663
+ }
12664
+ /**
12665
+ * Register an event handler for gateway events (tick, cron.changed, etc.).
12666
+ */
12667
+ on(event, handler) {
12668
+ let handlers = this.eventHandlers.get(event);
12669
+ if (!handlers) {
12670
+ handlers = /* @__PURE__ */ new Set();
12671
+ this.eventHandlers.set(event, handlers);
12380
12672
  }
12381
- });
12382
- api.registerGatewayMethod("cohort-sync/cron-remove", async ({ context, params, respond }) => {
12383
- try {
12384
- const result = await context.cron.remove(params.jobId);
12385
- respond(true, result);
12386
- } catch (err) {
12387
- respond(false, void 0, {
12388
- code: "CRON_REMOVE_FAILED",
12389
- message: (err instanceof Error ? err.message : String(err)).slice(0, 200)
12673
+ handlers.add(handler);
12674
+ }
12675
+ /**
12676
+ * Send a request to the gateway and wait for the response.
12677
+ *
12678
+ * Validates the method against ALLOWED_METHODS to prevent accidentally
12679
+ * calling admin or unauthorized methods.
12680
+ */
12681
+ async request(method, params, timeoutMs = 1e4) {
12682
+ if (!ALLOWED_METHODS.has(method)) {
12683
+ throw new Error(`Method "${method}" is not in ALLOWED_METHODS`);
12684
+ }
12685
+ if (!this.isAlive()) {
12686
+ throw new Error("Gateway client is not connected");
12687
+ }
12688
+ const id = crypto2.randomUUID();
12689
+ const frame = {
12690
+ type: "req",
12691
+ id,
12692
+ method,
12693
+ params
12694
+ };
12695
+ const pending = getPendingRequests();
12696
+ return new Promise((resolve, reject) => {
12697
+ const timer = setTimeout(() => {
12698
+ pending.delete(id);
12699
+ reject(new Error(`Gateway method "${method}" timed out after ${timeoutMs}ms`));
12700
+ }, timeoutMs);
12701
+ pending.set(id, {
12702
+ resolve,
12703
+ reject,
12704
+ timer
12390
12705
  });
12706
+ this.ws.send(JSON.stringify(frame));
12707
+ });
12708
+ }
12709
+ /**
12710
+ * Clean shutdown — close WebSocket, clear timers, reject pending requests.
12711
+ */
12712
+ close() {
12713
+ this.closed = true;
12714
+ this.alive = false;
12715
+ this.stopTickWatchdog();
12716
+ if (this.reconnectTimer) {
12717
+ clearTimeout(this.reconnectTimer);
12718
+ this.reconnectTimer = null;
12391
12719
  }
12392
- });
12393
- api.registerGatewayMethod("cohort-sync/cron-add", async ({ context, params, respond }) => {
12720
+ const pending = getPendingRequests();
12721
+ for (const [, entry] of pending) {
12722
+ clearTimeout(entry.timer);
12723
+ entry.reject(new Error("Gateway client closed"));
12724
+ }
12725
+ pending.clear();
12726
+ if (this.ws) {
12727
+ this.ws.close();
12728
+ this.ws = null;
12729
+ }
12730
+ diag("GW_CLIENT_CLOSED", { port: this.port });
12731
+ this.logger.info("cohort-sync: gateway client closed");
12732
+ }
12733
+ // -------------------------------------------------------------------------
12734
+ // Private methods
12735
+ // -------------------------------------------------------------------------
12736
+ /**
12737
+ * Route incoming WebSocket messages to the appropriate handler.
12738
+ *
12739
+ * Frame types:
12740
+ * - "res" → resolve/reject a pending request
12741
+ * - "event" → dispatch to registered event handlers; reset tick watchdog on tick
12742
+ */
12743
+ handleMessage(event) {
12394
12744
  try {
12395
- const result = await context.cron.add(params.job);
12396
- respond(true, result);
12397
- } catch (err) {
12398
- respond(false, void 0, {
12399
- code: "CRON_ADD_FAILED",
12400
- message: (err instanceof Error ? err.message : String(err)).slice(0, 200)
12401
- });
12745
+ const data = JSON.parse(String(event.data));
12746
+ if (data.type === "res") {
12747
+ this.handleResponse(data);
12748
+ } else if (data.type === "event") {
12749
+ this.handleEvent(data);
12750
+ }
12751
+ } catch {
12402
12752
  }
12403
- });
12404
- }
12753
+ }
12754
+ handleResponse(frame) {
12755
+ const pending = getPendingRequests();
12756
+ const entry = pending.get(frame.id);
12757
+ if (!entry) return;
12758
+ pending.delete(frame.id);
12759
+ clearTimeout(entry.timer);
12760
+ if (frame.ok) {
12761
+ entry.resolve(frame.payload);
12762
+ } else {
12763
+ entry.reject(new Error(`Gateway method failed: ${frame.error?.message ?? "unknown error"}`));
12764
+ }
12765
+ }
12766
+ handleEvent(frame) {
12767
+ if (frame.event === "tick") {
12768
+ this.resetTickWatchdog();
12769
+ }
12770
+ this.emit(frame.event, frame.payload);
12771
+ }
12772
+ emit(event, payload) {
12773
+ const handlers = this.eventHandlers.get(event);
12774
+ if (handlers) {
12775
+ for (const handler of handlers) {
12776
+ try {
12777
+ handler(payload);
12778
+ } catch (err) {
12779
+ this.logger.error(`cohort-sync: event handler error for "${event}": ${String(err)}`);
12780
+ }
12781
+ }
12782
+ }
12783
+ }
12784
+ /**
12785
+ * Start the tick watchdog timer.
12786
+ *
12787
+ * The gateway sends tick events at tickIntervalMs. If we don't receive
12788
+ * one within 2.5x that interval, the connection is considered dead.
12789
+ */
12790
+ startTickWatchdog(tickIntervalMs) {
12791
+ this.stopTickWatchdog();
12792
+ const watchdogMs = Math.round(tickIntervalMs * 2.5);
12793
+ this.tickWatchdog = setTimeout(() => {
12794
+ diag("GW_CLIENT_TICK_TIMEOUT", { watchdogMs });
12795
+ this.logger.warn(`cohort-sync: tick watchdog expired after ${watchdogMs}ms \u2014 closing connection`);
12796
+ this.alive = false;
12797
+ this.ws?.close();
12798
+ }, watchdogMs);
12799
+ }
12800
+ resetTickWatchdog() {
12801
+ if (this.tickWatchdog) {
12802
+ this.startTickWatchdog(this.tickIntervalMs);
12803
+ }
12804
+ }
12805
+ stopTickWatchdog() {
12806
+ if (this.tickWatchdog) {
12807
+ clearTimeout(this.tickWatchdog);
12808
+ this.tickWatchdog = null;
12809
+ }
12810
+ }
12811
+ /**
12812
+ * Schedule a reconnection attempt with exponential backoff and jitter.
12813
+ *
12814
+ * Backoff: 1s base, doubles each attempt, capped at 30s.
12815
+ * Jitter: +/- 25% randomization to avoid thundering herd.
12816
+ */
12817
+ scheduleReconnect() {
12818
+ if (this.closed) return;
12819
+ const baseMs = Math.min(1e3 * Math.pow(2, this.reconnectAttempts), 3e4);
12820
+ const jitter = baseMs * 0.25 * (Math.random() * 2 - 1);
12821
+ const delayMs = Math.round(Math.max(baseMs + jitter, 1e3));
12822
+ this.reconnectAttempts++;
12823
+ diag("GW_CLIENT_RECONNECT_SCHEDULED", {
12824
+ attempt: this.reconnectAttempts,
12825
+ delayMs
12826
+ });
12827
+ this.reconnectTimer = setTimeout(async () => {
12828
+ this.reconnectTimer = null;
12829
+ try {
12830
+ diag("GW_CLIENT_RECONNECTING", { attempt: this.reconnectAttempts });
12831
+ await this.connect();
12832
+ diag("GW_CLIENT_RECONNECTED", { attempt: this.reconnectAttempts });
12833
+ } catch (err) {
12834
+ diag("GW_CLIENT_RECONNECT_FAILED", {
12835
+ attempt: this.reconnectAttempts,
12836
+ error: String(err)
12837
+ });
12838
+ }
12839
+ }, delayMs);
12840
+ }
12841
+ };
12405
12842
 
12406
12843
  // src/agent-state.ts
12407
12844
  import { basename } from "node:path";
@@ -12902,20 +13339,6 @@ var POCKET_GUIDE = `# Cohort Agent Guide (Pocket Version)
12902
13339
 
12903
13340
  // src/hooks.ts
12904
13341
  var BUILD_ID = "B9-ACCOUNTID-20260311";
12905
- var DIAG_LOG_PATH = "/tmp/cohort-sync-diag.log";
12906
- function diag(label, data) {
12907
- const ts = (/* @__PURE__ */ new Date()).toISOString();
12908
- const payload = data ? " " + JSON.stringify(data, (_k, v2) => {
12909
- if (typeof v2 === "string" && v2.length > 200) return v2.slice(0, 200) + "\u2026";
12910
- return v2;
12911
- }) : "";
12912
- const line = `[${ts}] ${label}${payload}
12913
- `;
12914
- try {
12915
- fs.appendFileSync(DIAG_LOG_PATH, line);
12916
- } catch {
12917
- }
12918
- }
12919
13342
  function dumpCtx(ctx) {
12920
13343
  if (!ctx || typeof ctx !== "object") return { _raw: String(ctx) };
12921
13344
  const out = {};
@@ -12940,16 +13363,64 @@ function dumpEvent(event) {
12940
13363
  }
12941
13364
  var PLUGIN_VERSION = "unknown";
12942
13365
  try {
12943
- const pkgPath = path.join(path.dirname(new URL(import.meta.url).pathname), "package.json");
12944
- const pkgJson = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
13366
+ const pkgPath = path3.join(path3.dirname(new URL(import.meta.url).pathname), "package.json");
13367
+ const pkgJson = JSON.parse(fs3.readFileSync(pkgPath, "utf8"));
12945
13368
  PLUGIN_VERSION = pkgJson.version ?? "unknown";
12946
13369
  } catch {
12947
13370
  }
12948
13371
  diag("MODULE_LOADED", { BUILD_ID, PLUGIN_VERSION });
13372
+ var lastCronSnapshotJson = "";
13373
+ function resolveGatewayToken(api) {
13374
+ const rawToken = api.config?.gateway?.auth?.token;
13375
+ if (typeof rawToken === "string") return rawToken;
13376
+ if (rawToken && typeof rawToken === "object" && rawToken.source === "env") {
13377
+ return process.env[rawToken.id] ?? null;
13378
+ }
13379
+ return null;
13380
+ }
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
+ function registerCronEventHandlers(client2, cfg, resolveAgentName) {
13402
+ if (client2.availableEvents.has("cron")) {
13403
+ let debounceTimer = null;
13404
+ client2.on("cron", () => {
13405
+ if (debounceTimer) clearTimeout(debounceTimer);
13406
+ debounceTimer = setTimeout(async () => {
13407
+ try {
13408
+ const cronResult = await client2.request("cron.list", { includeDisabled: true });
13409
+ const jobs = Array.isArray(cronResult) ? cronResult : cronResult?.jobs ?? [];
13410
+ const mapped = jobs.map((j) => mapCronJob(j, resolveAgentName));
13411
+ await pushCronSnapshot(cfg.apiKey, mapped);
13412
+ diag("CRON_EVENT_PUSHED", { count: mapped.length });
13413
+ } catch (err) {
13414
+ diag("CRON_EVENT_PUSH_FAILED", { error: String(err) });
13415
+ }
13416
+ }, 2e3);
13417
+ });
13418
+ }
13419
+ }
12949
13420
  function parseIdentityFile(workspaceDir) {
12950
13421
  try {
12951
- const filePath = path.join(workspaceDir, "IDENTITY.md");
12952
- const content = fs.readFileSync(filePath, "utf-8");
13422
+ const filePath = path3.join(workspaceDir, "IDENTITY.md");
13423
+ const content = fs3.readFileSync(filePath, "utf-8");
12953
13424
  const identity = {};
12954
13425
  for (const line of content.split(/\r?\n/)) {
12955
13426
  const cleaned = line.trim().replace(/^\s*-\s*/, "");
@@ -12987,10 +13458,7 @@ function getOrCreateTracker() {
12987
13458
  state.tracker = fresh;
12988
13459
  return fresh;
12989
13460
  }
12990
- var STATE_FILE_PATH = path.join(
12991
- path.dirname(new URL(import.meta.url).pathname),
12992
- ".session-state.json"
12993
- );
13461
+ var STATE_FILE_PATH = "";
12994
13462
  function saveSessionsToDisk(tracker) {
12995
13463
  try {
12996
13464
  const state = tracker.exportState();
@@ -13005,14 +13473,14 @@ function saveSessionsToDisk(tracker) {
13005
13473
  data.sessions.push({ agentName: name, key });
13006
13474
  }
13007
13475
  }
13008
- fs.writeFileSync(STATE_FILE_PATH, JSON.stringify(data));
13476
+ fs3.writeFileSync(STATE_FILE_PATH, JSON.stringify(data));
13009
13477
  } catch {
13010
13478
  }
13011
13479
  }
13012
13480
  function loadSessionsFromDisk(tracker, logger) {
13013
13481
  try {
13014
- if (!fs.existsSync(STATE_FILE_PATH)) return;
13015
- const data = JSON.parse(fs.readFileSync(STATE_FILE_PATH, "utf8"));
13482
+ if (!fs3.existsSync(STATE_FILE_PATH)) return;
13483
+ const data = JSON.parse(fs3.readFileSync(STATE_FILE_PATH, "utf8"));
13016
13484
  if (Date.now() - new Date(data.savedAt).getTime() > 864e5) {
13017
13485
  logger.info("cohort-sync: disk session state too old (>24h), skipping");
13018
13486
  return;
@@ -13054,12 +13522,12 @@ async function fetchAgentContext(apiKey, apiUrl, logger) {
13054
13522
  }
13055
13523
  }
13056
13524
  function registerHooks(api, cfg) {
13525
+ STATE_FILE_PATH = path3.join(cfg.stateDir, "session-state.json");
13057
13526
  const { logger, config } = api;
13058
13527
  const nameMap = cfg.agentNameMap;
13059
13528
  const tracker = getOrCreateTracker();
13060
13529
  let heartbeatInterval = null;
13061
13530
  let activityFlushInterval = null;
13062
- registerCronGatewayMethods(api);
13063
13531
  logger.info(`cohort-sync: registerHooks [${BUILD_ID}]`);
13064
13532
  diag("REGISTER_HOOKS", {
13065
13533
  BUILD_ID,
@@ -13179,18 +13647,19 @@ function registerHooks(api, cfg) {
13179
13647
  logger.info(`cohort-sync: flushed ${pendingActivity.length} pending activity entries before hot-reload`);
13180
13648
  }
13181
13649
  clearIntervalsFromHot();
13650
+ const heartbeatMs = cfg.syncIntervalMs ?? 12e4;
13182
13651
  heartbeatInterval = setInterval(() => {
13183
13652
  pushHeartbeat().catch((err) => {
13184
13653
  logger.warn(`cohort-sync: heartbeat tick failed: ${String(err)}`);
13185
13654
  });
13186
- }, 12e4);
13655
+ }, heartbeatMs);
13187
13656
  activityFlushInterval = setInterval(() => {
13188
13657
  flushActivityBuffer().catch((err) => {
13189
13658
  logger.warn(`cohort-sync: activity flush tick failed: ${String(err)}`);
13190
13659
  });
13191
13660
  }, 3e3);
13192
13661
  saveIntervalsToHot(heartbeatInterval, activityFlushInterval);
13193
- logger.info("cohort-sync: intervals created (heartbeat=2m, activityFlush=3s)");
13662
+ logger.info(`cohort-sync: intervals created (heartbeat=${heartbeatMs / 1e3}s, activityFlush=3s)`);
13194
13663
  {
13195
13664
  const hotState = getHotState();
13196
13665
  if (hotState.commandSubscription) {
@@ -13200,6 +13669,19 @@ function registerHooks(api, cfg) {
13200
13669
  const unsub = initCommandSubscription(cfg, logger, resolveAgentName);
13201
13670
  hotState.commandSubscription = unsub;
13202
13671
  }
13672
+ {
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
+ });
13683
+ }
13684
+ }
13203
13685
  api.registerTool((toolCtx) => {
13204
13686
  const agentId = toolCtx.agentId ?? "main";
13205
13687
  const agentName = resolveAgentName(agentId);
@@ -13349,18 +13831,6 @@ Do not attempt to make more comments until ${resetAt}.`
13349
13831
  }
13350
13832
  }
13351
13833
  saveSessionsToDisk(tracker);
13352
- const ws = getHotState().gatewayWs;
13353
- if (ws && ws.readyState === WebSocket.OPEN) {
13354
- try {
13355
- const jobs = await callGatewayMethod(ws, "cohort-sync/cron-list");
13356
- const mapped = jobs.map((j) => mapCronJob(j, resolveAgentName));
13357
- await pushCronSnapshot(cfg.apiKey, mapped);
13358
- } catch (err) {
13359
- logger.warn(`cohort-sync: cron list failed: ${String(err)}`);
13360
- }
13361
- } else {
13362
- logger.warn("cohort-sync: gateway WS not connected \u2014 skipping cron snapshot");
13363
- }
13364
13834
  logger.info(`cohort-sync: heartbeat pushed for ${allAgentIds.length} agents`);
13365
13835
  }
13366
13836
  async function flushActivityBuffer() {
@@ -13400,20 +13870,28 @@ Do not attempt to make more comments until ${resetAt}.`
13400
13870
  diag("GATEWAY_START_BRIDGE_AFTER_SEED", { bridge: { ...getHotState().channelAgentBridge } });
13401
13871
  const hotState = getHotState();
13402
13872
  hotState.gatewayPort = event.port;
13403
- const rawToken = api.config?.gateway?.auth?.token;
13404
- if (typeof rawToken === "string") {
13405
- hotState.gatewayToken = rawToken;
13406
- } else if (rawToken && typeof rawToken === "object" && rawToken.source === "env") {
13407
- hotState.gatewayToken = process.env[rawToken.id] ?? null;
13408
- } else {
13409
- hotState.gatewayToken = null;
13410
- }
13411
- if (!hotState.gatewayWs || hotState.gatewayWs.readyState !== WebSocket.OPEN) {
13412
- if (hotState.gatewayToken) {
13413
- hotState.gatewayWs = openGatewayConnection(event.port, hotState.gatewayToken, logger);
13414
- } else {
13415
- logger.warn("cohort-sync: no gateway auth token in config \u2014 cron operations disabled");
13873
+ const token = resolveGatewayToken(api);
13874
+ if (token) {
13875
+ diag("GW_CLIENT_CONNECTING", { port: event.port, hasToken: true });
13876
+ 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 });
13888
+ } catch (err) {
13889
+ diag("GW_CLIENT_CONNECT_FAILED", { error: String(err) });
13890
+ logger.error(`cohort-sync: gateway client connect failed: ${String(err)}`);
13416
13891
  }
13892
+ } else {
13893
+ diag("GW_CLIENT_NO_TOKEN", {});
13894
+ logger.warn("cohort-sync: no gateway auth token \u2014 cron operations disabled");
13417
13895
  }
13418
13896
  await initSubscription(
13419
13897
  event.port,
@@ -13857,7 +14335,7 @@ function registerCohortCli(ctx, cfg) {
13857
14335
  agents.push({ id, name });
13858
14336
  }
13859
14337
  } else {
13860
- agents.push({ id: "main", name: "openclaw-cohort-sync" });
14338
+ agents.push({ id: "main", name: "main" });
13861
14339
  }
13862
14340
  const manifest = { agents };
13863
14341
  logger.info("cohort: Starting device authorization...");
@@ -13917,40 +14395,10 @@ function registerCohortCli(ctx, cfg) {
13917
14395
  // index.ts
13918
14396
  init_keychain();
13919
14397
  var DEFAULT_API_URL = "https://fortunate-chipmunk-286.convex.site";
13920
- async function doActivate(api) {
13921
- const cfg = api.pluginConfig;
13922
- const apiUrl = cfg?.apiUrl ?? DEFAULT_API_URL;
13923
- api.logger.info(`cohort-sync: activating (api: ${apiUrl})`);
13924
- let apiKey = cfg?.apiKey;
13925
- if (!apiKey) {
13926
- try {
13927
- apiKey = await getCredential(apiUrl) ?? void 0;
13928
- } catch (err) {
13929
- api.logger.error(
13930
- `cohort-sync: keychain lookup failed: ${err instanceof Error ? err.message : String(err)}`
13931
- );
13932
- }
13933
- }
13934
- if (!apiKey) {
13935
- api.logger.warn(
13936
- "cohort-sync: no API key found \u2014 run 'openclaw cohort auth' to authenticate"
13937
- );
13938
- return;
13939
- }
13940
- api.logger.info(`cohort-sync: activated (api: ${apiUrl})`);
13941
- registerHooks(api, {
13942
- apiUrl,
13943
- apiKey,
13944
- agentNameMap: cfg?.agentNameMap
13945
- });
13946
- }
13947
14398
  var plugin = {
13948
14399
  id: "cohort-sync",
13949
14400
  name: "Cohort Sync",
13950
14401
  description: "Syncs agent status and skills to Cohort dashboard",
13951
- // register() is synchronous — the SDK does not await it.
13952
- // We register CLI here, then self-activate async (the gateway does not
13953
- // call activate() for extension-directory plugins).
13954
14402
  register(api) {
13955
14403
  const cfg = api.pluginConfig;
13956
14404
  const apiUrl = cfg?.apiUrl ?? DEFAULT_API_URL;
@@ -13962,15 +14410,40 @@ var plugin = {
13962
14410
  }),
13963
14411
  { commands: ["cohort"] }
13964
14412
  );
13965
- doActivate(api).catch(
13966
- (err) => api.logger.error(
13967
- `cohort-sync: self-activation failed: ${err instanceof Error ? err.message : String(err)}`
13968
- )
13969
- );
13970
- },
13971
- // activate() kept for forward-compatibility if gateway adds activate() support.
13972
- async activate(api) {
13973
- return doActivate(api);
14413
+ api.registerService({
14414
+ id: "cohort-sync-core",
14415
+ async start(svcCtx) {
14416
+ api.logger.info(`cohort-sync: service starting (api: ${apiUrl})`);
14417
+ let apiKey = cfg?.apiKey;
14418
+ if (!apiKey) {
14419
+ try {
14420
+ apiKey = await getCredential(apiUrl) ?? void 0;
14421
+ } catch (err) {
14422
+ api.logger.error(
14423
+ `cohort-sync: keychain lookup failed: ${err instanceof Error ? err.message : String(err)}`
14424
+ );
14425
+ }
14426
+ }
14427
+ if (!apiKey) {
14428
+ api.logger.warn(
14429
+ "cohort-sync: no API key found \u2014 run 'openclaw cohort auth' to authenticate"
14430
+ );
14431
+ return;
14432
+ }
14433
+ api.logger.info(`cohort-sync: activated (api: ${apiUrl})`);
14434
+ registerHooks(api, {
14435
+ apiUrl,
14436
+ apiKey,
14437
+ stateDir: svcCtx.stateDir,
14438
+ agentNameMap: cfg?.agentNameMap,
14439
+ syncIntervalMs: cfg?.syncIntervalMs
14440
+ });
14441
+ },
14442
+ async stop() {
14443
+ closeSubscription();
14444
+ api.logger.info("cohort-sync: service stopped");
14445
+ }
14446
+ });
13974
14447
  }
13975
14448
  };
13976
14449
  var index_default = plugin;