@cfio/cohort-sync 0.8.1 → 0.9.2

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
@@ -44,20 +44,20 @@ function isNotFoundError(err) {
44
44
  }
45
45
  return false;
46
46
  }
47
- async function setCredential(apiUrl, apiKey) {
47
+ async function setCredential(apiUrl2, apiKey2) {
48
48
  assertMacOS("storing credentials");
49
49
  await securityCmd([
50
50
  "add-generic-password",
51
51
  "-s",
52
52
  SERVICE,
53
53
  "-a",
54
- apiUrl,
54
+ apiUrl2,
55
55
  "-w",
56
- apiKey,
56
+ apiKey2,
57
57
  "-U"
58
58
  ]);
59
59
  }
60
- async function getCredential(apiUrl) {
60
+ async function getCredential(apiUrl2) {
61
61
  assertMacOS("reading credentials");
62
62
  try {
63
63
  const { stdout } = await securityCmd([
@@ -65,7 +65,7 @@ async function getCredential(apiUrl) {
65
65
  "-s",
66
66
  SERVICE,
67
67
  "-a",
68
- apiUrl,
68
+ apiUrl2,
69
69
  "-w"
70
70
  ]);
71
71
  return stdout.trim();
@@ -74,7 +74,7 @@ async function getCredential(apiUrl) {
74
74
  throw err;
75
75
  }
76
76
  }
77
- async function deleteCredential(apiUrl) {
77
+ async function deleteCredential(apiUrl2) {
78
78
  assertMacOS("deleting credentials");
79
79
  try {
80
80
  await securityCmd([
@@ -82,7 +82,7 @@ async function deleteCredential(apiUrl) {
82
82
  "-s",
83
83
  SERVICE,
84
84
  "-a",
85
- apiUrl
85
+ apiUrl2
86
86
  ]);
87
87
  return true;
88
88
  } catch (err) {
@@ -98,11 +98,6 @@ var init_keychain = __esm({
98
98
  }
99
99
  });
100
100
 
101
- // src/hooks.ts
102
- import fs3 from "node:fs";
103
- import os3 from "node:os";
104
- import path3 from "node:path";
105
-
106
101
  // ../../node_modules/.pnpm/@sinclair+typebox@0.34.48/node_modules/@sinclair/typebox/build/esm/type/guard/value.mjs
107
102
  var value_exports = {};
108
103
  __export(value_exports, {
@@ -2709,17 +2704,43 @@ __export(type_exports2, {
2709
2704
  // ../../node_modules/.pnpm/@sinclair+typebox@0.34.48/node_modules/@sinclair/typebox/build/esm/type/type/index.mjs
2710
2705
  var Type = type_exports2;
2711
2706
 
2707
+ // src/hooks.ts
2708
+ import fs3 from "node:fs";
2709
+ import os3 from "node:os";
2710
+ import path3 from "node:path";
2711
+
2712
2712
  // src/sync.ts
2713
2713
  import { execSync } from "node:child_process";
2714
2714
  function extractJson(raw) {
2715
2715
  const jsonStart = raw.search(/[\[{]/);
2716
- const jsonEndBracket = raw.lastIndexOf("]");
2717
- const jsonEndBrace = raw.lastIndexOf("}");
2718
- const jsonEnd = Math.max(jsonEndBracket, jsonEndBrace);
2719
- if (jsonStart === -1 || jsonEnd === -1 || jsonEnd < jsonStart) {
2720
- throw new Error("No JSON found in output");
2716
+ if (jsonStart === -1) throw new Error("No JSON found in output");
2717
+ const openChar = raw[jsonStart];
2718
+ const closeChar = openChar === "[" ? "]" : "}";
2719
+ let depth = 0;
2720
+ let inString = false;
2721
+ let escape = false;
2722
+ for (let i = jsonStart; i < raw.length; i++) {
2723
+ const ch = raw[i];
2724
+ if (escape) {
2725
+ escape = false;
2726
+ continue;
2727
+ }
2728
+ if (ch === "\\") {
2729
+ escape = true;
2730
+ continue;
2731
+ }
2732
+ if (ch === '"') {
2733
+ inString = !inString;
2734
+ continue;
2735
+ }
2736
+ if (inString) continue;
2737
+ if (ch === openChar) depth++;
2738
+ else if (ch === closeChar) {
2739
+ depth--;
2740
+ if (depth === 0) return raw.slice(jsonStart, i + 1);
2741
+ }
2721
2742
  }
2722
- return raw.slice(jsonStart, jsonEnd + 1);
2743
+ throw new Error("No complete JSON found in output");
2723
2744
  }
2724
2745
  function fetchSkills(logger) {
2725
2746
  try {
@@ -2746,32 +2767,45 @@ var VALID_STATUSES = /* @__PURE__ */ new Set(["idle", "working", "waiting"]);
2746
2767
  function normalizeStatus(status) {
2747
2768
  return VALID_STATUSES.has(status) ? status : "idle";
2748
2769
  }
2749
- async function v1Get(apiUrl, apiKey, path4) {
2750
- const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
2751
- headers: { Authorization: `Bearer ${apiKey}` },
2770
+ async function v1Get(apiUrl2, apiKey2, path4) {
2771
+ const res = await fetch(`${apiUrl2.replace(/\/+$/, "")}${path4}`, {
2772
+ headers: { Authorization: `Bearer ${apiKey2}` },
2752
2773
  signal: AbortSignal.timeout(1e4)
2753
2774
  });
2754
2775
  if (!res.ok) throw new Error(`GET ${path4} \u2192 ${res.status}`);
2755
2776
  return res.json();
2756
2777
  }
2757
- async function v1Patch(apiUrl, apiKey, path4, body) {
2758
- const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
2778
+ async function v1Patch(apiUrl2, apiKey2, path4, body) {
2779
+ const res = await fetch(`${apiUrl2.replace(/\/+$/, "")}${path4}`, {
2759
2780
  method: "PATCH",
2760
- headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
2781
+ headers: { Authorization: `Bearer ${apiKey2}`, "Content-Type": "application/json" },
2761
2782
  body: JSON.stringify(body),
2762
2783
  signal: AbortSignal.timeout(1e4)
2763
2784
  });
2764
2785
  if (!res.ok) throw new Error(`PATCH ${path4} \u2192 ${res.status}`);
2765
2786
  }
2766
- async function v1Post(apiUrl, apiKey, path4, body) {
2767
- const res = await fetch(`${apiUrl.replace(/\/+$/, "")}${path4}`, {
2787
+ async function v1Post(apiUrl2, apiKey2, path4, body) {
2788
+ const res = await fetch(`${apiUrl2.replace(/\/+$/, "")}${path4}`, {
2768
2789
  method: "POST",
2769
- headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
2790
+ headers: { Authorization: `Bearer ${apiKey2}`, "Content-Type": "application/json" },
2770
2791
  body: JSON.stringify(body),
2771
2792
  signal: AbortSignal.timeout(1e4)
2772
2793
  });
2773
2794
  if (!res.ok) throw new Error(`POST ${path4} \u2192 ${res.status}`);
2774
2795
  }
2796
+ function isNewerVersion(a, b) {
2797
+ const strip = (v2) => v2.replace(/-.*$/, "");
2798
+ const pa = strip(a).split(".").map(Number);
2799
+ const pb = strip(b).split(".").map(Number);
2800
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
2801
+ const na = pa[i] ?? 0;
2802
+ const nb = pb[i] ?? 0;
2803
+ if (isNaN(na) || isNaN(nb)) return false;
2804
+ if (na > nb) return true;
2805
+ if (na < nb) return false;
2806
+ }
2807
+ return false;
2808
+ }
2775
2809
  async function checkForUpdate(currentVersion, logger) {
2776
2810
  try {
2777
2811
  const res = await fetch("https://registry.npmjs.org/@cfio/cohort-sync/latest", {
@@ -2780,9 +2814,9 @@ async function checkForUpdate(currentVersion, logger) {
2780
2814
  if (!res.ok) return;
2781
2815
  const data = await res.json();
2782
2816
  const latest = data.version;
2783
- if (latest && latest !== currentVersion) {
2817
+ if (latest && latest !== currentVersion && isNewerVersion(latest, currentVersion)) {
2784
2818
  logger.warn(
2785
- `cohort-sync: update available (${currentVersion} \u2192 ${latest}) \u2014 run "npm install -g @cfio/cohort-sync" to update`
2819
+ `cohort-sync: update available (${currentVersion} \u2192 ${latest}) \u2014 run "openclaw plugins install @cfio/cohort-sync" to update`
2786
2820
  );
2787
2821
  }
2788
2822
  } catch {
@@ -2812,16 +2846,20 @@ async function syncAgentStatus(agentName, status, model, cfg, logger) {
2812
2846
  }
2813
2847
  }
2814
2848
  async function syncSkillsToV1(skills, cfg, logger) {
2849
+ let synced = 0;
2815
2850
  for (const skill of skills) {
2816
2851
  try {
2817
2852
  await v1Post(cfg.apiUrl, cfg.apiKey, "/api/v1/skills", {
2818
2853
  name: skill.name,
2819
2854
  description: skill.description
2820
2855
  });
2856
+ synced++;
2857
+ if (synced % 5 === 0) await new Promise((r) => setTimeout(r, 500));
2821
2858
  } catch (err) {
2822
2859
  logger.warn(`cohort-sync: failed to sync skill "${skill.name}": ${String(err)}`);
2823
2860
  }
2824
2861
  }
2862
+ if (synced > 0) logger.info(`cohort-sync: synced ${synced}/${skills.length} skills`);
2825
2863
  }
2826
2864
  var lastKnownRoster = [];
2827
2865
  function getLastKnownRoster() {
@@ -11777,11 +11815,40 @@ function reverseResolveAgentName(cohortName, forwardMap) {
11777
11815
  }
11778
11816
 
11779
11817
  // src/commands.ts
11818
+ var RATE_LIMIT_WINDOW_MS = 6e4;
11819
+ var RATE_LIMIT_MAX = 10;
11820
+ var commandTimestamps = [];
11821
+ function checkRateLimit() {
11822
+ const now = Date.now();
11823
+ while (commandTimestamps.length > 0 && commandTimestamps[0] < now - RATE_LIMIT_WINDOW_MS) {
11824
+ commandTimestamps.shift();
11825
+ }
11826
+ if (commandTimestamps.length >= RATE_LIMIT_MAX) {
11827
+ return false;
11828
+ }
11829
+ commandTimestamps.push(now);
11830
+ return true;
11831
+ }
11832
+ var MAX_CRON_MESSAGE_LENGTH = 1e3;
11780
11833
  async function executeCommand(cmd, gwClient, cfg, resolveAgentName, logger) {
11834
+ logger.info(`cohort-sync: executing command type=${cmd.type} id=${cmd._id}`);
11835
+ if (!checkRateLimit()) {
11836
+ logger.warn(`cohort-sync: rate limit exceeded (>${RATE_LIMIT_MAX} commands/min), rejecting command ${cmd._id}`);
11837
+ throw new Error(`Rate limit exceeded: more than ${RATE_LIMIT_MAX} commands per minute`);
11838
+ }
11839
+ if (cmd.payload?.message && cmd.payload.message.length > MAX_CRON_MESSAGE_LENGTH) {
11840
+ logger.warn(`cohort-sync: cron message too long (${cmd.payload.message.length} chars, max ${MAX_CRON_MESSAGE_LENGTH}), rejecting command ${cmd._id}`);
11841
+ throw new Error(`Cron message exceeds maximum length of ${MAX_CRON_MESSAGE_LENGTH} characters`);
11842
+ }
11781
11843
  if (cmd.type === "restart") {
11782
- logger.info("cohort-sync: restart command, terminating in 500ms");
11783
- await new Promise((r) => setTimeout(r, 500));
11784
- process.kill(process.pid, "SIGTERM");
11844
+ if (gwClient && gwClient.isAlive()) {
11845
+ logger.warn(`cohort-sync: RESTART command received (id=${cmd._id}), issuing graceful gateway restart`);
11846
+ await gwClient.request("gateway.restart", { reason: "Cohort restart command" });
11847
+ } else {
11848
+ logger.warn(`cohort-sync: RESTART command received (id=${cmd._id}), no gateway client \u2014 falling back to SIGTERM`);
11849
+ await new Promise((r) => setTimeout(r, 500));
11850
+ process.kill(process.pid, "SIGTERM");
11851
+ }
11785
11852
  return;
11786
11853
  }
11787
11854
  if (cmd.type.startsWith("cron")) {
@@ -11861,12 +11928,12 @@ async function executeCommand(cmd, gwClient, cfg, resolveAgentName, logger) {
11861
11928
  function hashApiKey(key) {
11862
11929
  return createHash("sha256").update(key).digest("hex");
11863
11930
  }
11864
- function deriveConvexUrl(apiUrl) {
11865
- const normalized = apiUrl.replace(/\/+$/, "");
11931
+ function deriveConvexUrl(apiUrl2) {
11932
+ const normalized = apiUrl2.replace(/\/+$/, "");
11866
11933
  if (/^https?:\/\/api\.cohort\.bot$/i.test(normalized)) {
11867
11934
  return normalized.replace(/api\.cohort\.bot$/i, "ws.cohort.bot");
11868
11935
  }
11869
- return apiUrl.replace(/\.convex\.site\/?$/, ".convex.cloud");
11936
+ return apiUrl2.replace(/\.convex\.site\/?$/, ".convex.cloud");
11870
11937
  }
11871
11938
  var savedLogger = null;
11872
11939
  function setLogger(logger) {
@@ -11887,6 +11954,7 @@ function createClient(convexUrl) {
11887
11954
  client.close();
11888
11955
  }
11889
11956
  savedConvexUrl = convexUrl;
11957
+ authCircuitOpen = false;
11890
11958
  client = new ConvexClient(convexUrl);
11891
11959
  return client;
11892
11960
  }
@@ -11914,6 +11982,17 @@ function closeBridge() {
11914
11982
  }
11915
11983
  savedConvexUrl = null;
11916
11984
  }
11985
+ var authCircuitOpen = false;
11986
+ function isUnauthorizedError(err) {
11987
+ return String(err).includes("Unauthorized");
11988
+ }
11989
+ function tripAuthCircuit() {
11990
+ if (authCircuitOpen) return;
11991
+ authCircuitOpen = true;
11992
+ getLogger().error(
11993
+ "cohort-sync: API key rejected \u2014 all outbound mutations disabled until gateway restart. Re-run `openclaw cohort auth` to issue a new key, then restart the gateway."
11994
+ );
11995
+ }
11917
11996
  var commandUnsubscriber = null;
11918
11997
  var upsertTelemetryFromPlugin = makeFunctionReference("telemetryPlugin:upsertTelemetryFromPlugin");
11919
11998
  var upsertSessionsFromPlugin = makeFunctionReference("telemetryPlugin:upsertSessionsFromPlugin");
@@ -11925,62 +12004,101 @@ var getPendingCommandsForPlugin = makeFunctionReference("gatewayCommands:getPend
11925
12004
  var acknowledgeCommandRef = makeFunctionReference("gatewayCommands:acknowledgeCommand");
11926
12005
  var failCommandRef = makeFunctionReference("gatewayCommands:failCommand");
11927
12006
  var addCommentFromPluginRef = makeFunctionReference("comments:addCommentFromPlugin");
11928
- async function pushTelemetry(apiKey, data) {
12007
+ async function pushTelemetry(apiKey2, data) {
12008
+ if (authCircuitOpen) return;
11929
12009
  const c = getClient();
11930
12010
  if (!c) return;
11931
12011
  try {
11932
- await c.mutation(upsertTelemetryFromPlugin, { apiKeyHash: hashApiKey(apiKey), ...data });
12012
+ await c.mutation(upsertTelemetryFromPlugin, { apiKeyHash: hashApiKey(apiKey2), ...data });
11933
12013
  } catch (err) {
12014
+ if (isUnauthorizedError(err)) {
12015
+ tripAuthCircuit();
12016
+ return;
12017
+ }
11934
12018
  getLogger().error(`cohort-sync: pushTelemetry failed: ${err}`);
11935
12019
  }
11936
12020
  }
11937
- async function pushSessions(apiKey, agentName, sessions) {
12021
+ async function pushSessions(apiKey2, agentName, sessions) {
12022
+ if (authCircuitOpen) return;
11938
12023
  const c = getClient();
11939
12024
  if (!c) return;
11940
12025
  try {
11941
- await c.mutation(upsertSessionsFromPlugin, { apiKeyHash: hashApiKey(apiKey), agentName, sessions });
12026
+ await c.mutation(upsertSessionsFromPlugin, { apiKeyHash: hashApiKey(apiKey2), agentName, sessions });
11942
12027
  } catch (err) {
12028
+ if (isUnauthorizedError(err)) {
12029
+ tripAuthCircuit();
12030
+ return;
12031
+ }
11943
12032
  getLogger().error(`cohort-sync: pushSessions failed: ${err}`);
11944
12033
  }
11945
12034
  }
11946
- async function pushActivity(apiKey, entries) {
11947
- if (entries.length === 0) return;
12035
+ async function pushActivity(apiKey2, entries) {
12036
+ if (authCircuitOpen || entries.length === 0) return;
11948
12037
  const c = getClient();
11949
12038
  if (!c) return;
11950
12039
  try {
11951
- await c.mutation(pushActivityFromPluginRef, { apiKeyHash: hashApiKey(apiKey), entries });
12040
+ await c.mutation(pushActivityFromPluginRef, { apiKeyHash: hashApiKey(apiKey2), entries });
11952
12041
  } catch (err) {
12042
+ if (isUnauthorizedError(err)) {
12043
+ tripAuthCircuit();
12044
+ return;
12045
+ }
11953
12046
  getLogger().error(`cohort-sync: pushActivity failed: ${err}`);
11954
12047
  }
11955
12048
  }
11956
- async function pushCronSnapshot(apiKey, jobs) {
12049
+ async function pushCronSnapshot(apiKey2, jobs) {
12050
+ if (authCircuitOpen) return false;
11957
12051
  const c = getClient();
11958
12052
  if (!c) return false;
11959
12053
  try {
11960
- await c.mutation(upsertCronSnapshotFromPluginRef, { apiKeyHash: hashApiKey(apiKey), jobs });
12054
+ await c.mutation(upsertCronSnapshotFromPluginRef, { apiKeyHash: hashApiKey(apiKey2), jobs });
11961
12055
  return true;
11962
12056
  } catch (err) {
12057
+ if (isUnauthorizedError(err)) {
12058
+ tripAuthCircuit();
12059
+ return false;
12060
+ }
11963
12061
  getLogger().error(`cohort-sync: pushCronSnapshot failed: ${err}`);
11964
12062
  return false;
11965
12063
  }
11966
12064
  }
11967
- async function callAddCommentFromPlugin(apiKey, args) {
12065
+ async function callAddCommentFromPlugin(apiKey2, args) {
12066
+ if (authCircuitOpen) {
12067
+ throw new Error("API key rejected \u2014 re-run `openclaw cohort auth` and restart the gateway");
12068
+ }
11968
12069
  const c = getClient();
11969
12070
  if (!c) {
11970
12071
  throw new Error("Convex client not initialized \u2014 subscription may not be active");
11971
12072
  }
11972
- return await c.mutation(addCommentFromPluginRef, {
11973
- apiKeyHash: hashApiKey(apiKey),
11974
- taskNumber: args.taskNumber,
11975
- agentName: args.agentName,
11976
- content: args.content,
11977
- noReply: args.noReply
11978
- });
12073
+ try {
12074
+ return await c.mutation(addCommentFromPluginRef, {
12075
+ apiKeyHash: hashApiKey(apiKey2),
12076
+ taskNumber: args.taskNumber,
12077
+ agentName: args.agentName,
12078
+ content: args.content,
12079
+ noReply: args.noReply
12080
+ });
12081
+ } catch (err) {
12082
+ if (isUnauthorizedError(err)) {
12083
+ tripAuthCircuit();
12084
+ }
12085
+ throw err;
12086
+ }
11979
12087
  }
11980
12088
  var DEFAULT_BEHAVIORAL_PROMPT = `BEFORE RESPONDING:
11981
12089
  - Does your planned response address the task's stated scope? If not, do not comment.
11982
12090
  - Do not post acknowledgment-only responses ("got it", "sounds good", "confirmed"). If you have no new information to add, do not comment.
11983
12091
  - If the work is complete, transition the task to "waiting" and set noReply=true on your final comment, then stop working on this task.`;
12092
+ var TOOLS_REFERENCE = `
12093
+ TOOLS: Use these \u2014 do NOT call the REST API directly.
12094
+ - cohort_comment(task_number, comment) \u2014 post a comment
12095
+ - cohort_task(task_number) \u2014 fetch full task details + comments
12096
+ - cohort_transition(task_number, status) \u2014 change status
12097
+ - cohort_assign(task_number, assignee) \u2014 assign/unassign
12098
+ - cohort_context() \u2014 get your session briefing`;
12099
+ function sanitizePreview(raw) {
12100
+ return raw.replace(/<\/?user_comment>/gi, "");
12101
+ }
11984
12102
  function buildNotificationMessage(n) {
11985
12103
  let header;
11986
12104
  let cta;
@@ -11989,11 +12107,11 @@ function buildNotificationMessage(n) {
11989
12107
  if (n.isMentioned) {
11990
12108
  header = `You were @mentioned on task #${n.taskNumber} "${n.taskTitle}"
11991
12109
  By: ${n.actorName}`;
11992
- cta = "You were directly mentioned. Read the comment and respond using the cohort_comment tool.";
12110
+ cta = `You were directly mentioned. Read the comment and respond using the cohort_comment tool (taskNumber: ${n.taskNumber}).`;
11993
12111
  } else {
11994
12112
  header = `New comment on task #${n.taskNumber} "${n.taskTitle}"
11995
12113
  From: ${n.actorName}`;
11996
- cta = "Read the comment thread and respond using the cohort_comment tool if appropriate.";
12114
+ cta = `You were NOT @mentioned \u2014 you are receiving this because you are subscribed to this task. Do not respond unless you see something incorrect, urgent, or directly relevant to your expertise that others have missed.`;
11997
12115
  }
11998
12116
  break;
11999
12117
  case "assignment":
@@ -12011,8 +12129,19 @@ By: ${n.actorName}`;
12011
12129
  From: ${n.actorName}`;
12012
12130
  cta = "Check the task and respond if needed.";
12013
12131
  }
12014
- const body = n.preview ? `
12015
- Comment: "${n.preview}"` : "";
12132
+ let body = "";
12133
+ if (n.preview) {
12134
+ if (n.type === "comment") {
12135
+ const safe = sanitizePreview(n.preview);
12136
+ body = `
12137
+ <user_comment>
12138
+ ${safe}
12139
+ </user_comment>`;
12140
+ } else {
12141
+ body = `
12142
+ Comment: "${n.preview}"`;
12143
+ }
12144
+ }
12016
12145
  let scope = "";
12017
12146
  if (n.taskDescription && n.type === "comment") {
12018
12147
  const truncated = n.taskDescription.length > 500 ? n.taskDescription.slice(0, 500) + "..." : n.taskDescription;
@@ -12029,7 +12158,7 @@ ${DEFAULT_BEHAVIORAL_PROMPT}` : DEFAULT_BEHAVIORAL_PROMPT;
12029
12158
  ${prompt}` : "";
12030
12159
  return `${header}${scope}${body}
12031
12160
 
12032
- ${cta}${promptBlock}`;
12161
+ ${cta}${promptBlock}${TOOLS_REFERENCE}`;
12033
12162
  }
12034
12163
  async function injectNotification(port, hooksToken, n, agentId = "main") {
12035
12164
  const response = await fetch(`http://localhost:${port}/hooks/agent`, {
@@ -12044,13 +12173,16 @@ async function injectNotification(port, hooksToken, n, agentId = "main") {
12044
12173
  agentId,
12045
12174
  deliver: false,
12046
12175
  sessionKey: `hook:cohort:task-${n.taskNumber}`
12047
- })
12176
+ }),
12177
+ signal: AbortSignal.timeout(1e4)
12048
12178
  });
12049
12179
  if (!response.ok) {
12050
12180
  throw new Error(`/hooks/agent returned ${response.status} ${response.statusText}`);
12051
12181
  }
12052
12182
  }
12053
- async function startNotificationSubscription(port, cfg, hooksToken, logger) {
12183
+ var deliveryFailures = /* @__PURE__ */ new Map();
12184
+ var MAX_DELIVERY_ATTEMPTS = 3;
12185
+ async function startNotificationSubscription(port, cfg, hooksToken, logger, gwClient) {
12054
12186
  const c = getClient();
12055
12187
  if (!c) {
12056
12188
  logger.warn("cohort-sync: no ConvexClient \u2014 notification subscription skipped");
@@ -12060,7 +12192,6 @@ async function startNotificationSubscription(port, cfg, hooksToken, logger) {
12060
12192
  logger.warn(
12061
12193
  `cohort-sync: hooks.token not configured \u2014 real-time notifications disabled (telemetry will still be pushed).`
12062
12194
  );
12063
- return;
12064
12195
  }
12065
12196
  const agentNames = cfg.agentNameMap ? Object.values(cfg.agentNameMap) : ["main"];
12066
12197
  const reverseNameMap = {};
@@ -12078,19 +12209,49 @@ async function startNotificationSubscription(port, cfg, hooksToken, logger) {
12078
12209
  getUndeliveredForPlugin,
12079
12210
  { agent: agentName, apiKeyHash },
12080
12211
  async (notifications) => {
12081
- if (processing) return;
12212
+ if (authCircuitOpen || processing) return;
12082
12213
  processing = true;
12083
12214
  try {
12084
12215
  for (const n of notifications) {
12216
+ const failCount = deliveryFailures.get(n._id) ?? 0;
12217
+ if (failCount >= MAX_DELIVERY_ATTEMPTS) {
12218
+ continue;
12219
+ }
12085
12220
  try {
12086
- await injectNotification(port, hooksToken, n, openclawAgentId);
12087
- logger.info(`cohort-sync: injected notification for task #${n.taskNumber} (${agentName})`);
12221
+ if (hooksToken) {
12222
+ await injectNotification(port, hooksToken, n, openclawAgentId);
12223
+ logger.info(`cohort-sync: injected notification for task #${n.taskNumber} (${agentName})`);
12224
+ } else {
12225
+ throw new Error(
12226
+ `no transport available for notification ${n._id} (gwClient alive: ${gwClient?.isAlive() ?? "null"}, hooksToken: ${!!hooksToken})`
12227
+ );
12228
+ }
12088
12229
  await c.mutation(markDeliveredByPlugin, {
12089
12230
  notificationId: n._id,
12090
12231
  apiKeyHash
12091
12232
  });
12233
+ deliveryFailures.delete(n._id);
12092
12234
  } catch (err) {
12093
- logger.warn(`cohort-sync: failed to inject notification ${n._id}: ${String(err)}`);
12235
+ const newFailCount = failCount + 1;
12236
+ deliveryFailures.set(n._id, newFailCount);
12237
+ if (newFailCount >= MAX_DELIVERY_ATTEMPTS) {
12238
+ logger.error(
12239
+ `cohort-sync: dead-letter notification ${n._id} for task #${n.taskNumber} (${n.type} from ${n.actorName}) after ${MAX_DELIVERY_ATTEMPTS} failed delivery attempts: ${String(err)}`
12240
+ );
12241
+ try {
12242
+ await c.mutation(markDeliveredByPlugin, {
12243
+ notificationId: n._id,
12244
+ apiKeyHash
12245
+ });
12246
+ deliveryFailures.delete(n._id);
12247
+ } catch (markErr) {
12248
+ logger.error(`cohort-sync: failed to dead-letter ${n._id}: ${String(markErr)}`);
12249
+ }
12250
+ } else {
12251
+ logger.warn(
12252
+ `cohort-sync: failed to inject notification ${n._id} (attempt ${newFailCount}/${MAX_DELIVERY_ATTEMPTS}): ${String(err)}`
12253
+ );
12254
+ }
12094
12255
  }
12095
12256
  }
12096
12257
  } finally {
@@ -12098,6 +12259,10 @@ async function startNotificationSubscription(port, cfg, hooksToken, logger) {
12098
12259
  }
12099
12260
  },
12100
12261
  (err) => {
12262
+ if (isUnauthorizedError(err)) {
12263
+ tripAuthCircuit();
12264
+ return;
12265
+ }
12101
12266
  logger.error(`cohort-sync: subscription error for "${agentName}": ${String(err)}`);
12102
12267
  }
12103
12268
  );
@@ -12116,7 +12281,7 @@ function startCommandSubscription(cfg, logger, resolveAgentName, gwClient) {
12116
12281
  getPendingCommandsForPlugin,
12117
12282
  { apiKeyHash },
12118
12283
  async (commands) => {
12119
- if (processing) return;
12284
+ if (authCircuitOpen || processing) return;
12120
12285
  if (commands.length === 0) return;
12121
12286
  processing = true;
12122
12287
  try {
@@ -12130,6 +12295,10 @@ function startCommandSubscription(cfg, logger, resolveAgentName, gwClient) {
12130
12295
  await executeCommand(cmd, gwClient, cfg, resolveAgentName, logger);
12131
12296
  if (cmd.type === "restart") return;
12132
12297
  } catch (err) {
12298
+ if (isUnauthorizedError(err)) {
12299
+ tripAuthCircuit();
12300
+ return;
12301
+ }
12133
12302
  logger.error(`cohort-sync: failed to process command ${cmd._id}: ${String(err)}`);
12134
12303
  try {
12135
12304
  await c.mutation(failCommandRef, {
@@ -12138,6 +12307,10 @@ function startCommandSubscription(cfg, logger, resolveAgentName, gwClient) {
12138
12307
  reason: String(err).slice(0, 500)
12139
12308
  });
12140
12309
  } catch (failErr) {
12310
+ if (isUnauthorizedError(failErr)) {
12311
+ tripAuthCircuit();
12312
+ return;
12313
+ }
12141
12314
  logger.error(`cohort-sync: failed to mark command ${cmd._id} as failed: ${String(failErr)}`);
12142
12315
  }
12143
12316
  }
@@ -12147,6 +12320,10 @@ function startCommandSubscription(cfg, logger, resolveAgentName, gwClient) {
12147
12320
  }
12148
12321
  },
12149
12322
  (err) => {
12323
+ if (isUnauthorizedError(err)) {
12324
+ tripAuthCircuit();
12325
+ return;
12326
+ }
12150
12327
  logger.error(`cohort-sync: command subscription error: ${String(err)}`);
12151
12328
  }
12152
12329
  );
@@ -12271,7 +12448,8 @@ var ALLOWED_METHODS = /* @__PURE__ */ new Set([
12271
12448
  "sessions.preview",
12272
12449
  "agent",
12273
12450
  "snapshot",
12274
- "system.presence"
12451
+ "system.presence",
12452
+ "gateway.restart"
12275
12453
  ]);
12276
12454
  function buildConnectFrame(id, token, pluginVersion, identity, nonce) {
12277
12455
  const signedAtMs = Date.now();
@@ -12330,8 +12508,9 @@ function parseHelloOk(response) {
12330
12508
  throw new Error(`Unexpected payload type: ${String(payload?.type ?? "missing")}`);
12331
12509
  }
12332
12510
  const policy = payload.policy ?? {};
12333
- const methods = payload.methods ?? [];
12334
- const events = payload.events ?? [];
12511
+ const features = payload.features;
12512
+ const methods = features?.methods ?? payload.methods ?? [];
12513
+ const events = features?.events ?? payload.events ?? [];
12335
12514
  return {
12336
12515
  methods: new Set(methods),
12337
12516
  events: new Set(events),
@@ -12803,6 +12982,15 @@ function parseSessionKey(key) {
12803
12982
  if (rest[0] === "main") {
12804
12983
  return { kind: "direct", channel: "signal" };
12805
12984
  }
12985
+ if (rest[0] === "cron") {
12986
+ return { kind: "cron", channel: "cron", identifier: rest.slice(1).join(":") };
12987
+ }
12988
+ if (rest[0] === "subagent") {
12989
+ return { kind: "subagent", channel: "subagent", identifier: rest.slice(1).join(":") };
12990
+ }
12991
+ if (rest[0] === "acp") {
12992
+ return { kind: "acp", channel: "acp", identifier: rest.slice(1).join(":") };
12993
+ }
12806
12994
  if (rest[0] === "signal") {
12807
12995
  if (rest[1] === "group") {
12808
12996
  return { kind: "group", channel: "signal", identifier: rest.slice(2).join(":") };
@@ -12816,6 +13004,14 @@ function parseSessionKey(key) {
12816
13004
  if (rest[0] === "slack" && rest[1] === "channel") {
12817
13005
  return { kind: "group", channel: "slack", identifier: rest[2] };
12818
13006
  }
13007
+ const channel = rest[0];
13008
+ if (rest.includes("group") || rest.includes("channel")) {
13009
+ return { kind: "group", channel, identifier: rest.slice(1).join(":") };
13010
+ }
13011
+ if (rest.includes("dm") || rest.includes("direct")) {
13012
+ return { kind: "direct", channel, identifier: rest.slice(1).join(":") };
13013
+ }
13014
+ return { kind: "direct", channel, identifier: rest.slice(1).join(":") || void 0 };
12819
13015
  }
12820
13016
  return { kind: "cli", channel: "cli" };
12821
13017
  }
@@ -13293,6 +13489,30 @@ var POCKET_GUIDE = `# Cohort Agent Guide (Pocket Version)
13293
13489
  - Don't ignore workspace-specific overrides from your context response.
13294
13490
  `;
13295
13491
 
13492
+ // src/types.ts
13493
+ var DEFAULT_API_URL = "https://api.cohort.bot";
13494
+
13495
+ // src/tool-runtime.ts
13496
+ var apiKey = null;
13497
+ var apiUrl = DEFAULT_API_URL;
13498
+ var resolveAgentNameFn = null;
13499
+ var loggerRef = null;
13500
+ function setToolRuntime(state) {
13501
+ apiKey = state.apiKey;
13502
+ apiUrl = state.apiUrl;
13503
+ resolveAgentNameFn = state.resolveAgentName;
13504
+ loggerRef = state.logger;
13505
+ }
13506
+ function getToolRuntime() {
13507
+ return {
13508
+ apiKey,
13509
+ apiUrl,
13510
+ resolveAgentName: resolveAgentNameFn,
13511
+ logger: loggerRef,
13512
+ isReady: apiKey !== null && resolveAgentNameFn !== null
13513
+ };
13514
+ }
13515
+
13296
13516
  // src/hooks.ts
13297
13517
  var REDACT_KEYS = /* @__PURE__ */ new Set([
13298
13518
  "token",
@@ -13340,6 +13560,12 @@ try {
13340
13560
  } catch {
13341
13561
  }
13342
13562
  diag("MODULE_LOADED", { PLUGIN_VERSION });
13563
+ var _gatewayStartHandler = null;
13564
+ async function handleGatewayStart(event) {
13565
+ if (_gatewayStartHandler) {
13566
+ await _gatewayStartHandler(event);
13567
+ }
13568
+ }
13343
13569
  function resolveGatewayToken(api) {
13344
13570
  const rawToken = api.config?.gateway?.auth?.token;
13345
13571
  if (typeof rawToken === "string") return rawToken;
@@ -13425,7 +13651,7 @@ function saveSessionsToDisk(tracker2) {
13425
13651
  data.sessions.push({ agentName: name, key });
13426
13652
  }
13427
13653
  }
13428
- fs3.writeFileSync(STATE_FILE_PATH, JSON.stringify(data));
13654
+ fs3.writeFileSync(STATE_FILE_PATH, JSON.stringify(data), { mode: 384 });
13429
13655
  } catch {
13430
13656
  }
13431
13657
  }
@@ -13456,23 +13682,6 @@ function loadSessionsFromDisk(tracker2, logger) {
13456
13682
  } catch {
13457
13683
  }
13458
13684
  }
13459
- async function fetchAgentContext(apiKey, apiUrl, logger) {
13460
- try {
13461
- const response = await fetch(`${apiUrl}/api/v1/context`, {
13462
- method: "GET",
13463
- headers: { "Authorization": `Bearer ${apiKey}` }
13464
- });
13465
- if (!response.ok) {
13466
- logger.warn(`cohort-sync: /context returned ${response.status}, using pocket guide`);
13467
- return POCKET_GUIDE;
13468
- }
13469
- const data = await response.json();
13470
- return data.briefing || POCKET_GUIDE;
13471
- } catch (err) {
13472
- logger.warn(`cohort-sync: /context fetch failed: ${String(err)}, using pocket guide`);
13473
- return POCKET_GUIDE;
13474
- }
13475
- }
13476
13685
  function initGatewayClient(port, token, cfg, resolveAgentName, logger) {
13477
13686
  const client2 = new GatewayClient(port, token, logger, PLUGIN_VERSION);
13478
13687
  persistentGwClient = client2;
@@ -13557,6 +13766,12 @@ function registerHooks(api, cfg) {
13557
13766
  function resolveAgentName(agentId) {
13558
13767
  return (nameMap?.[agentId] ?? identityNameMap[agentId] ?? agentId).toLowerCase();
13559
13768
  }
13769
+ setToolRuntime({
13770
+ apiKey: cfg.apiKey,
13771
+ apiUrl: cfg.apiUrl,
13772
+ resolveAgentName,
13773
+ logger
13774
+ });
13560
13775
  function resolveAgentFromContext(ctx) {
13561
13776
  const allCtxKeys = Object.keys(ctx);
13562
13777
  diag("RESOLVE_AGENT_FROM_CTX_START", {
@@ -13638,101 +13853,6 @@ function registerHooks(api, cfg) {
13638
13853
  const unsub = startCommandSubscription(cfg, logger, resolveAgentName, persistentGwClient);
13639
13854
  commandUnsubscriber2 = unsub;
13640
13855
  }
13641
- api.registerTool((toolCtx) => {
13642
- const agentId = toolCtx.agentId ?? "main";
13643
- const agentName = resolveAgentName(agentId);
13644
- return {
13645
- name: "cohort_comment",
13646
- label: "cohort_comment",
13647
- description: "Post a comment on a Cohort task. Use this to respond to @mentions or collaborate on tasks.",
13648
- parameters: Type.Object({
13649
- task_number: Type.Number({ description: "Task number (e.g. 312)" }),
13650
- comment: Type.String({ description: "Comment text to post" }),
13651
- no_reply: Type.Optional(Type.Boolean({
13652
- description: "If true, no notifications will be sent for this comment. Use for final/closing comments."
13653
- }))
13654
- }),
13655
- async execute(_toolCallId, params) {
13656
- try {
13657
- const result = await callAddCommentFromPlugin(cfg.apiKey, {
13658
- taskNumber: params.task_number,
13659
- agentName,
13660
- content: params.comment,
13661
- noReply: params.no_reply ?? false
13662
- });
13663
- const lines = [`Comment posted on task #${params.task_number}.`];
13664
- if (result.stats) {
13665
- lines.push("");
13666
- lines.push(`This task has ${result.stats.totalComments} comments. ${result.stats.myRecentCount}/${result.stats.threshold} hourly limit used on this task.`);
13667
- }
13668
- if (result.budget) {
13669
- lines.push(`Daily budget: ${result.budget.used}/${result.budget.limit}`);
13670
- }
13671
- return {
13672
- content: [{ type: "text", text: lines.join("\n") }],
13673
- details: result
13674
- };
13675
- } catch (err) {
13676
- const msg = err instanceof Error ? err.message : String(err);
13677
- if (msg.includes("AGENT_COMMENTS_LOCKED")) {
13678
- return {
13679
- content: [{
13680
- type: "text",
13681
- text: `Cannot comment on task #${params.task_number}.
13682
- Reason: Agent comments are locked on this task.
13683
- Do not re-attempt to comment on this task.`
13684
- }],
13685
- details: { error: "AGENT_COMMENTS_LOCKED", taskNumber: params.task_number }
13686
- };
13687
- }
13688
- if (msg.includes("TASK_HOUR_LIMIT_REACHED")) {
13689
- const parts = msg.split("|");
13690
- const count = parts[1] ?? "?";
13691
- const limit = parts[2] ?? "?";
13692
- return {
13693
- content: [{
13694
- type: "text",
13695
- text: `Cannot comment on task #${params.task_number}.
13696
- Reason: You have posted ${count} comments on this task in the last hour (limit: ${limit}).
13697
- Step back from this task. Do not comment again until the next hour.`
13698
- }],
13699
- details: { error: "TASK_HOUR_LIMIT_REACHED", count, limit, taskNumber: params.task_number }
13700
- };
13701
- }
13702
- if (msg.includes("DAILY_LIMIT_REACHED")) {
13703
- const parts = msg.split("|");
13704
- const used = parts[1] ?? "?";
13705
- const max = parts[2] ?? "?";
13706
- const resetAt = parts[3] ?? "tomorrow";
13707
- return {
13708
- content: [{
13709
- type: "text",
13710
- text: `Cannot comment on task #${params.task_number}.
13711
- Reason: Daily comment limit reached (${used}/${max}).
13712
- Do not attempt to make more comments until ${resetAt}.`
13713
- }],
13714
- details: { error: "DAILY_LIMIT_REACHED", used, max, resetAt, taskNumber: params.task_number }
13715
- };
13716
- }
13717
- throw err;
13718
- }
13719
- }
13720
- };
13721
- });
13722
- api.registerTool(() => {
13723
- return {
13724
- name: "cohort_context",
13725
- label: "cohort_context",
13726
- description: "Get your Cohort session briefing. Call this at the start of every work session to receive your guidelines, current assignments, active projects, and recent team activity.",
13727
- parameters: Type.Object({}),
13728
- async execute() {
13729
- const briefing = await fetchAgentContext(cfg.apiKey, cfg.apiUrl, logger);
13730
- return {
13731
- content: [{ type: "text", text: briefing }]
13732
- };
13733
- }
13734
- };
13735
- });
13736
13856
  function resolveModel(agentId) {
13737
13857
  const agent = config?.agents?.list?.find((a) => a.id === agentId);
13738
13858
  const m = agent?.model;
@@ -13752,7 +13872,7 @@ Do not attempt to make more comments until ${resetAt}.`
13752
13872
  if (m.includes("deepseek")) return 128e3;
13753
13873
  return 2e5;
13754
13874
  }
13755
- api.on("gateway_start", async (event) => {
13875
+ _gatewayStartHandler = async (event) => {
13756
13876
  diag("HOOK_gateway_start", { port: event.port, eventKeys: Object.keys(event) });
13757
13877
  try {
13758
13878
  checkForUpdate(PLUGIN_VERSION, logger).catch(() => {
@@ -13803,7 +13923,8 @@ Do not attempt to make more comments until ${resetAt}.`
13803
13923
  event.port,
13804
13924
  cfg,
13805
13925
  api.config.hooks?.token,
13806
- logger
13926
+ logger,
13927
+ persistentGwClient
13807
13928
  ).catch((err) => {
13808
13929
  logger.error(`cohort-sync: subscription init failed: ${String(err)}`);
13809
13930
  });
@@ -13840,7 +13961,7 @@ Do not attempt to make more comments until ${resetAt}.`
13840
13961
  saveSessionsToDisk(tracker2);
13841
13962
  }, KEEPALIVE_INTERVAL_MS);
13842
13963
  logger.info(`cohort-sync: keepalive interval started (${KEEPALIVE_INTERVAL_MS / 1e3}s)`);
13843
- });
13964
+ };
13844
13965
  api.on("agent_end", async (event, ctx) => {
13845
13966
  diag("HOOK_agent_end", { ctx: dumpCtx(ctx), success: event.success, error: event.error, durationMs: event.durationMs });
13846
13967
  const agentId = ctx.agentId ?? "main";
@@ -14190,11 +14311,11 @@ Do not attempt to make more comments until ${resetAt}.`
14190
14311
  import { execFile as execFile2 } from "node:child_process";
14191
14312
 
14192
14313
  // src/device-auth.ts
14193
- function baseUrl(apiUrl) {
14194
- return apiUrl.replace(/\/+$/, "");
14314
+ function baseUrl(apiUrl2) {
14315
+ return apiUrl2.replace(/\/+$/, "");
14195
14316
  }
14196
- async function startDeviceAuth(apiUrl, manifest) {
14197
- const url = `${baseUrl(apiUrl)}/api/v1/device-auth/start`;
14317
+ async function startDeviceAuth(apiUrl2, manifest) {
14318
+ const url = `${baseUrl(apiUrl2)}/api/v1/device-auth/start`;
14198
14319
  const res = await fetch(url, {
14199
14320
  method: "POST",
14200
14321
  headers: { "Content-Type": "application/json" },
@@ -14209,8 +14330,8 @@ async function startDeviceAuth(apiUrl, manifest) {
14209
14330
  }
14210
14331
  return res.json();
14211
14332
  }
14212
- async function pollDeviceAuth(apiUrl, deviceCode) {
14213
- const url = `${baseUrl(apiUrl)}/api/v1/device-auth/poll`;
14333
+ async function pollDeviceAuth(apiUrl2, deviceCode) {
14334
+ const url = `${baseUrl(apiUrl2)}/api/v1/device-auth/poll`;
14214
14335
  const res = await fetch(url, {
14215
14336
  method: "POST",
14216
14337
  headers: { "Content-Type": "application/json" },
@@ -14225,7 +14346,7 @@ async function pollDeviceAuth(apiUrl, deviceCode) {
14225
14346
  }
14226
14347
  return res.json();
14227
14348
  }
14228
- async function waitForApproval(apiUrl, deviceCode, opts) {
14349
+ async function waitForApproval(apiUrl2, deviceCode, opts) {
14229
14350
  const intervalMs = opts?.intervalMs ?? 5e3;
14230
14351
  const timeoutMs = opts?.timeoutMs ?? 9e5;
14231
14352
  const onPoll = opts?.onPoll;
@@ -14236,7 +14357,7 @@ async function waitForApproval(apiUrl, deviceCode, opts) {
14236
14357
  return { status: "timeout" };
14237
14358
  }
14238
14359
  try {
14239
- const result = await pollDeviceAuth(apiUrl, deviceCode);
14360
+ const result = await pollDeviceAuth(apiUrl2, deviceCode);
14240
14361
  onPoll?.(result.status);
14241
14362
  if (result.status === "approved") {
14242
14363
  return { status: "approved", apiKey: result.apiKey };
@@ -14353,45 +14474,292 @@ var plugin = {
14353
14474
  description: "Syncs agent status and skills to Cohort dashboard",
14354
14475
  register(api) {
14355
14476
  const cfg = api.pluginConfig;
14356
- const apiUrl = cfg?.apiUrl;
14357
- if (!apiUrl) {
14477
+ const apiUrl2 = cfg?.apiUrl || DEFAULT_API_URL;
14478
+ if (!apiUrl2.startsWith("https://") && !apiUrl2.startsWith("http://localhost") && !apiUrl2.startsWith("http://127.0.0.1")) {
14358
14479
  api.logger.error(
14359
- "cohort-sync: apiUrl is required in plugin config \u2014 set it in your OpenClaw configuration"
14480
+ "cohort-sync: apiUrl must use HTTPS for security. Got: " + apiUrl2.replace(/\/\/.*@/, "//***@")
14360
14481
  );
14361
14482
  return;
14362
14483
  }
14363
14484
  api.registerCli(
14364
14485
  (ctx) => registerCohortCli(ctx, {
14365
- apiUrl,
14486
+ apiUrl: apiUrl2,
14366
14487
  apiKey: cfg?.apiKey,
14367
14488
  agentNameMap: cfg?.agentNameMap
14368
14489
  }),
14369
14490
  { commands: ["cohort"] }
14370
14491
  );
14492
+ const gatewayPort2 = api.config?.gateway?.port ?? 18789;
14493
+ api.registerHook(
14494
+ "gateway:startup",
14495
+ async (...args) => {
14496
+ const event = args[0] ?? {};
14497
+ const port = event?.port ?? gatewayPort2;
14498
+ api.logger.info(`cohort-sync: gateway:startup hook fired (port=${port})`);
14499
+ await handleGatewayStart({ ...event, port });
14500
+ },
14501
+ {
14502
+ name: "cohort-sync.gateway-startup",
14503
+ description: "Sync agents and start notification subscription on gateway startup"
14504
+ }
14505
+ );
14506
+ api.registerTool((toolCtx) => {
14507
+ const agentId = toolCtx.agentId ?? "main";
14508
+ return {
14509
+ name: "cohort_comment",
14510
+ label: "cohort_comment",
14511
+ description: "Post a comment on a Cohort task. Use this to respond to @mentions or collaborate on tasks.",
14512
+ parameters: Type.Object({
14513
+ task_number: Type.Number({ description: "Task number (e.g. 312)" }),
14514
+ comment: Type.String({ description: "Comment text to post" }),
14515
+ no_reply: Type.Optional(Type.Boolean({
14516
+ description: "If true, no notifications will be sent for this comment. Use for final/closing comments."
14517
+ }))
14518
+ }),
14519
+ async execute(_toolCallId, params) {
14520
+ const rt = getToolRuntime();
14521
+ if (!rt.isReady) {
14522
+ return {
14523
+ content: [{ type: "text", text: "cohort_comment is not ready yet \u2014 the plugin is still starting up. Try again in a few seconds." }]
14524
+ };
14525
+ }
14526
+ const agentName = rt.resolveAgentName(agentId);
14527
+ try {
14528
+ const result = await callAddCommentFromPlugin(rt.apiKey, {
14529
+ taskNumber: params.task_number,
14530
+ agentName,
14531
+ content: params.comment,
14532
+ noReply: params.no_reply ?? false
14533
+ });
14534
+ const lines = [`Comment posted on task #${params.task_number}.`];
14535
+ if (result.stats) {
14536
+ lines.push("");
14537
+ lines.push(`This task has ${result.stats.totalComments} comments. ${result.stats.myRecentCount}/${result.stats.threshold} hourly limit used on this task.`);
14538
+ }
14539
+ if (result.budget) {
14540
+ lines.push(`Daily budget: ${result.budget.used}/${result.budget.limit}`);
14541
+ }
14542
+ return {
14543
+ content: [{ type: "text", text: lines.join("\n") }],
14544
+ details: result
14545
+ };
14546
+ } catch (err) {
14547
+ const msg = err instanceof Error ? err.message : String(err);
14548
+ if (msg.includes("AGENT_COMMENTS_LOCKED")) {
14549
+ return { content: [{ type: "text", text: `Cannot comment on task #${params.task_number}.
14550
+ Reason: Agent comments are locked on this task.
14551
+ Do not re-attempt.` }] };
14552
+ }
14553
+ if (msg.includes("TASK_HOUR_LIMIT_REACHED")) {
14554
+ return { content: [{ type: "text", text: `Cannot comment on task #${params.task_number}.
14555
+ Reason: Per-task hourly limit reached.
14556
+ Step back from this task.` }] };
14557
+ }
14558
+ if (msg.includes("DAILY_LIMIT_REACHED")) {
14559
+ return { content: [{ type: "text", text: `Cannot comment on task #${params.task_number}.
14560
+ Reason: Daily comment limit reached.
14561
+ Do not attempt more comments until tomorrow.` }] };
14562
+ }
14563
+ throw err;
14564
+ }
14565
+ }
14566
+ };
14567
+ });
14568
+ api.registerTool(() => {
14569
+ return {
14570
+ name: "cohort_context",
14571
+ label: "cohort_context",
14572
+ description: "Get your Cohort session briefing. Call this at the start of every work session to receive your guidelines, current assignments, active projects, and recent team activity.",
14573
+ parameters: Type.Object({}),
14574
+ async execute() {
14575
+ const rt = getToolRuntime();
14576
+ if (!rt.isReady) {
14577
+ return { content: [{ type: "text", text: POCKET_GUIDE }] };
14578
+ }
14579
+ try {
14580
+ const response = await fetch(`${rt.apiUrl}/api/v1/context`, {
14581
+ method: "GET",
14582
+ headers: { "Authorization": `Bearer ${rt.apiKey}` },
14583
+ signal: AbortSignal.timeout(1e4)
14584
+ });
14585
+ if (!response.ok) return { content: [{ type: "text", text: POCKET_GUIDE }] };
14586
+ const data = await response.json();
14587
+ return { content: [{ type: "text", text: data.briefing || POCKET_GUIDE }] };
14588
+ } catch {
14589
+ return { content: [{ type: "text", text: POCKET_GUIDE }] };
14590
+ }
14591
+ }
14592
+ };
14593
+ });
14594
+ api.registerTool(() => {
14595
+ return {
14596
+ name: "cohort_task",
14597
+ label: "cohort_task",
14598
+ description: "Fetch full details for a Cohort task by number, including description and recent comments. Use this before responding to a notification if you need more context about the task.",
14599
+ parameters: Type.Object({
14600
+ task_number: Type.Number({ description: "Task number (e.g. 370)" }),
14601
+ include_comments: Type.Optional(Type.Boolean({ description: "Include recent comments (default: true)" })),
14602
+ comment_limit: Type.Optional(Type.Number({ description: "Max comments to return (default: 10)" }))
14603
+ }),
14604
+ async execute(_toolCallId, params) {
14605
+ const rt = getToolRuntime();
14606
+ if (!rt.isReady) {
14607
+ return { content: [{ type: "text", text: "cohort_task is not ready yet \u2014 the plugin is still starting up." }] };
14608
+ }
14609
+ try {
14610
+ const taskRes = await fetch(`${rt.apiUrl}/api/v1/tasks/${params.task_number}`, {
14611
+ headers: { "Authorization": `Bearer ${rt.apiKey}` },
14612
+ signal: AbortSignal.timeout(1e4)
14613
+ });
14614
+ if (!taskRes.ok) {
14615
+ return { content: [{ type: "text", text: `Task #${params.task_number} not found (${taskRes.status}).` }] };
14616
+ }
14617
+ const task = await taskRes.json();
14618
+ const lines = [
14619
+ `# Task #${task.taskNumber}: ${task.title}`,
14620
+ `**Status:** ${task.status} | **Priority:** ${task.priority ?? "none"} | **Effort:** ${task.effort ?? "none"}`,
14621
+ `**Assigned to:** ${task.assignedTo ?? "unassigned"}`,
14622
+ `**Created:** ${task.createdAt}`,
14623
+ "",
14624
+ "## Description",
14625
+ task.description || "(no description)"
14626
+ ];
14627
+ if (params.include_comments !== false) {
14628
+ const limit = params.comment_limit ?? 10;
14629
+ const commentsRes = await fetch(
14630
+ `${rt.apiUrl}/api/v1/tasks/${params.task_number}/comments?limit=${limit}`,
14631
+ {
14632
+ headers: { "Authorization": `Bearer ${rt.apiKey}` },
14633
+ signal: AbortSignal.timeout(1e4)
14634
+ }
14635
+ );
14636
+ if (commentsRes.ok) {
14637
+ const commentsData = await commentsRes.json();
14638
+ const comments = commentsData.data ?? [];
14639
+ if (comments.length > 0) {
14640
+ lines.push("", "## Recent Comments");
14641
+ for (const c of comments) {
14642
+ lines.push(`**${c.authorName}** (${c.authorType}, ${c.createdAt.slice(0, 16)}): ${c.body.slice(0, 300)}`);
14643
+ }
14644
+ if (comments.length >= limit) {
14645
+ lines.push(`(showing ${limit} most recent \u2014 there may be more)`);
14646
+ }
14647
+ }
14648
+ }
14649
+ }
14650
+ return { content: [{ type: "text", text: lines.join("\n") }] };
14651
+ } catch (err) {
14652
+ return { content: [{ type: "text", text: `Failed to fetch task #${params.task_number}: ${err instanceof Error ? err.message : String(err)}` }] };
14653
+ }
14654
+ }
14655
+ };
14656
+ });
14657
+ api.registerTool(() => {
14658
+ return {
14659
+ name: "cohort_transition",
14660
+ label: "cohort_transition",
14661
+ description: "Change the status of a Cohort task. Valid statuses: backlog, todo, in_progress, waiting, done. Always leave a comment explaining the transition before calling this.",
14662
+ parameters: Type.Object({
14663
+ task_number: Type.Number({ description: "Task number (e.g. 370)" }),
14664
+ status: Type.String({ description: "Target status: backlog, todo, in_progress, waiting, or done" })
14665
+ }),
14666
+ async execute(_toolCallId, params) {
14667
+ const rt = getToolRuntime();
14668
+ if (!rt.isReady) {
14669
+ return { content: [{ type: "text", text: "cohort_transition is not ready yet \u2014 the plugin is still starting up." }] };
14670
+ }
14671
+ const validStatuses = ["backlog", "todo", "in_progress", "waiting", "done"];
14672
+ if (!validStatuses.includes(params.status)) {
14673
+ return { content: [{ type: "text", text: `Invalid status "${params.status}". Valid statuses: ${validStatuses.join(", ")}` }] };
14674
+ }
14675
+ try {
14676
+ const res = await fetch(`${rt.apiUrl}/api/v1/tasks/${params.task_number}/transition`, {
14677
+ method: "POST",
14678
+ headers: {
14679
+ "Authorization": `Bearer ${rt.apiKey}`,
14680
+ "Content-Type": "application/json"
14681
+ },
14682
+ body: JSON.stringify({ to: params.status }),
14683
+ signal: AbortSignal.timeout(1e4)
14684
+ });
14685
+ if (!res.ok) {
14686
+ const body = await res.text();
14687
+ return { content: [{ type: "text", text: `Failed to transition task #${params.task_number} to "${params.status}": ${res.status} ${body.slice(0, 200)}` }] };
14688
+ }
14689
+ const task = await res.json();
14690
+ return { content: [{ type: "text", text: `Task #${params.task_number} transitioned to "${params.status}".` }] };
14691
+ } catch (err) {
14692
+ return { content: [{ type: "text", text: `Failed to transition task #${params.task_number}: ${err instanceof Error ? err.message : String(err)}` }] };
14693
+ }
14694
+ }
14695
+ };
14696
+ });
14697
+ api.registerTool((toolCtx) => {
14698
+ const agentId = toolCtx.agentId ?? "main";
14699
+ return {
14700
+ name: "cohort_assign",
14701
+ label: "cohort_assign",
14702
+ description: "Assign a Cohort task to a team member (agent or human) by name. Use your own name to self-assign. Set assignee to null or empty string to unassign.",
14703
+ parameters: Type.Object({
14704
+ task_number: Type.Number({ description: "Task number (e.g. 370)" }),
14705
+ assignee: Type.Union([
14706
+ Type.String({ description: "Name of the assignee (e.g. 'tosh', 'dave')" }),
14707
+ Type.Null({ description: "Set to null to unassign" })
14708
+ ])
14709
+ }),
14710
+ async execute(_toolCallId, params) {
14711
+ const rt = getToolRuntime();
14712
+ if (!rt.isReady) {
14713
+ return { content: [{ type: "text", text: "cohort_assign is not ready yet \u2014 the plugin is still starting up." }] };
14714
+ }
14715
+ try {
14716
+ const assignee = params.assignee?.trim() || null;
14717
+ const res = await fetch(`${rt.apiUrl}/api/v1/tasks/${params.task_number}`, {
14718
+ method: "PATCH",
14719
+ headers: {
14720
+ "Authorization": `Bearer ${rt.apiKey}`,
14721
+ "Content-Type": "application/json"
14722
+ },
14723
+ body: JSON.stringify({ assignedTo: assignee }),
14724
+ signal: AbortSignal.timeout(1e4)
14725
+ });
14726
+ if (!res.ok) {
14727
+ const body = await res.text();
14728
+ return { content: [{ type: "text", text: `Failed to assign task #${params.task_number}: ${res.status} ${body.slice(0, 200)}` }] };
14729
+ }
14730
+ const task = await res.json();
14731
+ const msg = assignee ? `Task #${params.task_number} assigned to ${assignee}.` : `Task #${params.task_number} unassigned.`;
14732
+ return { content: [{ type: "text", text: msg }] };
14733
+ } catch (err) {
14734
+ return { content: [{ type: "text", text: `Failed to assign task #${params.task_number}: ${err instanceof Error ? err.message : String(err)}` }] };
14735
+ }
14736
+ }
14737
+ };
14738
+ });
14371
14739
  api.registerService({
14372
14740
  id: "cohort-sync-core",
14373
14741
  async start(svcCtx) {
14374
- api.logger.info(`cohort-sync: service starting (api: ${apiUrl})`);
14375
- let apiKey = cfg?.apiKey;
14376
- if (!apiKey) {
14742
+ api.logger.info(`cohort-sync: service starting (api: ${apiUrl2})`);
14743
+ let apiKey2 = cfg?.apiKey;
14744
+ if (!apiKey2) {
14377
14745
  try {
14378
- apiKey = await getCredential(apiUrl) ?? void 0;
14746
+ apiKey2 = await getCredential(apiUrl2) ?? void 0;
14379
14747
  } catch (err) {
14380
14748
  api.logger.error(
14381
14749
  `cohort-sync: keychain lookup failed: ${err instanceof Error ? err.message : String(err)}`
14382
14750
  );
14383
14751
  }
14384
14752
  }
14385
- if (!apiKey) {
14753
+ if (!apiKey2) {
14386
14754
  api.logger.warn(
14387
14755
  "cohort-sync: no API key found \u2014 run 'openclaw cohort auth' to authenticate"
14388
14756
  );
14389
14757
  return;
14390
14758
  }
14391
- api.logger.info(`cohort-sync: activated (api: ${apiUrl})`);
14759
+ api.logger.info(`cohort-sync: activated (api: ${apiUrl2})`);
14392
14760
  registerHooks(api, {
14393
- apiUrl,
14394
- apiKey,
14761
+ apiUrl: apiUrl2,
14762
+ apiKey: apiKey2,
14395
14763
  stateDir: svcCtx.stateDir,
14396
14764
  agentNameMap: cfg?.agentNameMap
14397
14765
  });
@@ -35,7 +35,8 @@
35
35
  "additionalProperties": false,
36
36
  "properties": {
37
37
  "apiUrl": {
38
- "type": "string"
38
+ "type": "string",
39
+ "default": "https://api.cohort.bot"
39
40
  },
40
41
  "apiKey": {
41
42
  "type": "string"
@@ -54,5 +55,5 @@
54
55
  }
55
56
  }
56
57
  },
57
- "version": "0.8.1"
58
+ "version": "0.9.2"
58
59
  }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfio/cohort-sync",
3
- "version": "0.8.1",
3
+ "version": "0.9.2",
4
4
  "description": "Syncs agent status and skills to Cohort dashboard",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -0,0 +1,59 @@
1
+ {
2
+ "id": "cohort-sync",
3
+ "uiHints": {
4
+ "apiUrl": {
5
+ "label": "Cohort API URL",
6
+ "placeholder": "https://api.cohort.bot",
7
+ "help": "Base URL for the Cohort telemetry API"
8
+ },
9
+ "apiKey": {
10
+ "label": "API Key",
11
+ "sensitive": true,
12
+ "placeholder": "cohort_...",
13
+ "help": "Cohort API key for telemetry writes"
14
+ },
15
+ "syncIntervalMs": {
16
+ "label": "Sync Interval (ms)",
17
+ "placeholder": "300000",
18
+ "advanced": true,
19
+ "help": "Fallback full sync interval in milliseconds (default: 5 min)"
20
+ },
21
+ "agentNameMap": {
22
+ "label": "Agent Name Map",
23
+ "advanced": true,
24
+ "help": "Map OpenClaw agent IDs to Cohort display names (e.g. {\"main\": \"yuki\"})"
25
+ },
26
+ "convexUrl": {
27
+ "label": "Convex WebSocket URL",
28
+ "placeholder": "https://ws.cohort.bot",
29
+ "advanced": true,
30
+ "help": "Override the auto-derived Convex WebSocket URL (rarely needed)"
31
+ }
32
+ },
33
+ "configSchema": {
34
+ "type": "object",
35
+ "additionalProperties": false,
36
+ "properties": {
37
+ "apiUrl": {
38
+ "type": "string",
39
+ "default": "https://api.cohort.bot"
40
+ },
41
+ "apiKey": {
42
+ "type": "string"
43
+ },
44
+ "syncIntervalMs": {
45
+ "type": "number"
46
+ },
47
+ "agentNameMap": {
48
+ "type": "object",
49
+ "additionalProperties": {
50
+ "type": "string"
51
+ }
52
+ },
53
+ "convexUrl": {
54
+ "type": "string"
55
+ }
56
+ }
57
+ },
58
+ "version": "0.9.2"
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfio/cohort-sync",
3
- "version": "0.8.1",
3
+ "version": "0.9.2",
4
4
  "description": "Syncs agent status and skills to Cohort dashboard",
5
5
  "license": "MIT",
6
6
  "homepage": "https://docs.cohort.bot/gateway",
@@ -28,6 +28,7 @@
28
28
  },
29
29
  "files": [
30
30
  "dist",
31
+ "openclaw.plugin.json",
31
32
  "scripts/postinstall.mjs"
32
33
  ],
33
34
  "publishConfig": {