jefrichat-mcp 0.48.8 → 0.48.10

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.
Files changed (3) hide show
  1. package/dist/http.js +415 -68
  2. package/dist/index.js +117 -45
  3. package/package.json +1 -1
package/dist/http.js CHANGED
@@ -98,7 +98,7 @@ var require_depd = __commonJS({
98
98
  var site = callSiteLocation(stack[1]);
99
99
  var file = site[0];
100
100
  function deprecate(message) {
101
- log2.call(deprecate, message);
101
+ log3.call(deprecate, message);
102
102
  }
103
103
  deprecate._file = file;
104
104
  deprecate._ignored = isignored(namespace);
@@ -127,7 +127,7 @@ var require_depd = __commonJS({
127
127
  var str = process.env.TRACE_DEPRECATION || "";
128
128
  return containsNamespace(str, namespace);
129
129
  }
130
- function log2(message, site) {
130
+ function log3(message, site) {
131
131
  var haslisteners = eehaslisteners(process, "deprecation");
132
132
  if (!haslisteners && this._ignored) {
133
133
  return;
@@ -267,7 +267,7 @@ var require_depd = __commonJS({
267
267
  "message",
268
268
  "site",
269
269
  '"use strict"\nreturn function (' + args + ") {log.call(deprecate, message, site)\nreturn fn.apply(this, arguments)\n}"
270
- )(fn, log2, this, message, site);
270
+ )(fn, log3, this, message, site);
271
271
  return deprecatedfn;
272
272
  }
273
273
  function wrapproperty(obj, prop, message) {
@@ -292,13 +292,13 @@ var require_depd = __commonJS({
292
292
  var set = descriptor.set;
293
293
  if (typeof get === "function") {
294
294
  descriptor.get = function getter() {
295
- log2.call(deprecate, message, site);
295
+ log3.call(deprecate, message, site);
296
296
  return get.apply(this, arguments);
297
297
  };
298
298
  }
299
299
  if (typeof set === "function") {
300
300
  descriptor.set = function setter() {
301
- log2.call(deprecate, message, site);
301
+ log3.call(deprecate, message, site);
302
302
  return set.apply(this, arguments);
303
303
  };
304
304
  }
@@ -1135,7 +1135,7 @@ var require_debug = __commonJS({
1135
1135
  var require_browser = __commonJS({
1136
1136
  "../../node_modules/.pnpm/debug@2.6.9/node_modules/debug/src/browser.js"(exports, module) {
1137
1137
  exports = module.exports = require_debug();
1138
- exports.log = log2;
1138
+ exports.log = log3;
1139
1139
  exports.formatArgs = formatArgs;
1140
1140
  exports.save = save;
1141
1141
  exports.load = load;
@@ -1183,7 +1183,7 @@ var require_browser = __commonJS({
1183
1183
  });
1184
1184
  args.splice(lastC, 0, c);
1185
1185
  }
1186
- function log2() {
1186
+ function log3() {
1187
1187
  return "object" === typeof console && console.log && Function.prototype.apply.call(console.log, console, arguments);
1188
1188
  }
1189
1189
  function save(namespaces) {
@@ -1224,7 +1224,7 @@ var require_node = __commonJS({
1224
1224
  var util2 = __require("util");
1225
1225
  exports = module.exports = require_debug();
1226
1226
  exports.init = init;
1227
- exports.log = log2;
1227
+ exports.log = log3;
1228
1228
  exports.formatArgs = formatArgs;
1229
1229
  exports.save = save;
1230
1230
  exports.load = load;
@@ -1275,7 +1275,7 @@ var require_node = __commonJS({
1275
1275
  args[0] = (/* @__PURE__ */ new Date()).toUTCString() + " " + name + " " + args[0];
1276
1276
  }
1277
1277
  }
1278
- function log2() {
1278
+ function log3() {
1279
1279
  return stream.write(util2.format.apply(util2, arguments) + "\n");
1280
1280
  }
1281
1281
  function save(namespaces) {
@@ -27279,11 +27279,11 @@ var require_core = __commonJS({
27279
27279
  Ajv2.ValidationError = validation_error_1.default;
27280
27280
  Ajv2.MissingRefError = ref_error_1.default;
27281
27281
  exports.default = Ajv2;
27282
- function checkOptions(checkOpts, options, msg, log2 = "error") {
27282
+ function checkOptions(checkOpts, options, msg, log3 = "error") {
27283
27283
  for (const key in checkOpts) {
27284
27284
  const opt = key;
27285
27285
  if (opt in options)
27286
- this.logger[log2](`${msg}: option ${key}. ${checkOpts[opt]}`);
27286
+ this.logger[log3](`${msg}: option ${key}. ${checkOpts[opt]}`);
27287
27287
  }
27288
27288
  }
27289
27289
  function getSchEnv(keyRef) {
@@ -33549,7 +33549,7 @@ for (const [k, v] of snapshot) {
33549
33549
 
33550
33550
  // src/http.ts
33551
33551
  import http from "node:http";
33552
- import { randomUUID } from "node:crypto";
33552
+ import { randomUUID as randomUUID3 } from "node:crypto";
33553
33553
 
33554
33554
  // src/session-auth.ts
33555
33555
  import { createHash, timingSafeEqual } from "node:crypto";
@@ -49051,7 +49051,7 @@ var wrapper_default = import_websocket.default;
49051
49051
  import fs from "node:fs";
49052
49052
  import os from "node:os";
49053
49053
  import path2 from "node:path";
49054
- import { webcrypto } from "node:crypto";
49054
+ import { webcrypto, randomUUID as randomUUID2 } from "node:crypto";
49055
49055
 
49056
49056
  // ../../node_modules/.pnpm/bail@2.0.2/node_modules/bail/index.js
49057
49057
  function bail(error2) {
@@ -60649,6 +60649,234 @@ function groupConversationId(groupId) {
60649
60649
  return "group:" + groupId;
60650
60650
  }
60651
60651
 
60652
+ // ../sdk/src/http-client.ts
60653
+ import { randomUUID } from "node:crypto";
60654
+ function idem(key) {
60655
+ return { "idempotency-key": key || `sdk-${randomUUID()}` };
60656
+ }
60657
+ var JefriHttpError = class extends Error {
60658
+ constructor(message, status, body) {
60659
+ super(message);
60660
+ this.status = status;
60661
+ this.body = body;
60662
+ this.name = "JefriHttpError";
60663
+ }
60664
+ status;
60665
+ body;
60666
+ /** The hub's machine-readable state field, when it sent one. */
60667
+ get state() {
60668
+ return this.body?.state;
60669
+ }
60670
+ };
60671
+ var UnsupportedOverHttp = class extends Error {
60672
+ constructor(what, why) {
60673
+ super(`${what} is not available without a live connection \u2014 ${why}`);
60674
+ this.name = "UnsupportedOverHttp";
60675
+ }
60676
+ };
60677
+ var DEFAULT_TIMEOUT_MS = 3e4;
60678
+ var JefriHttpClient = class {
60679
+ base;
60680
+ /** Discriminant for callers that accept either client: `stateless` is true
60681
+ * here and absent on the socket client, so shared code can branch without
60682
+ * instanceof (which breaks across duplicated package instances). */
60683
+ stateless = true;
60684
+ /** PUBLIC, like JefriClient.token: the connector's HTTP upload/download
60685
+ * helpers authenticate raw fetches with it. */
60686
+ token;
60687
+ f;
60688
+ timeoutMs;
60689
+ /** Populated by whoami(); mirrors JefriClient.identity for callers that read it. */
60690
+ identity = null;
60691
+ constructor(opts) {
60692
+ this.base = opts.server.replace(/\/+$/, "");
60693
+ this.token = opts.token;
60694
+ this.f = opts.fetchImpl ?? fetch;
60695
+ const t = Number(opts.timeoutMs);
60696
+ this.timeoutMs = Number.isFinite(t) && t > 0 ? t : DEFAULT_TIMEOUT_MS;
60697
+ }
60698
+ async call(method, path3, body, query, extraHeaders) {
60699
+ const url = new URL(this.base + path3);
60700
+ for (const [k, v] of Object.entries(query ?? {})) if (v !== void 0 && v !== "") url.searchParams.set(k, v);
60701
+ const ac = new AbortController();
60702
+ const timer = setTimeout(() => ac.abort(), this.timeoutMs);
60703
+ let res;
60704
+ try {
60705
+ res = await this.f(url.toString(), {
60706
+ method,
60707
+ signal: ac.signal,
60708
+ headers: {
60709
+ authorization: `Bearer ${this.token}`,
60710
+ ...body !== void 0 ? { "content-type": "application/json" } : {},
60711
+ ...extraHeaders ?? {}
60712
+ },
60713
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
60714
+ });
60715
+ } catch (e) {
60716
+ clearTimeout(timer);
60717
+ if (e?.name === "AbortError") throw new Error(`${method} ${path3} timed out after ${this.timeoutMs}ms`);
60718
+ throw e;
60719
+ }
60720
+ let text5;
60721
+ try {
60722
+ text5 = await res.text();
60723
+ } catch (e) {
60724
+ if (e?.name === "AbortError") throw new Error(`${method} ${path3} timed out after ${this.timeoutMs}ms reading the body`);
60725
+ throw e;
60726
+ } finally {
60727
+ clearTimeout(timer);
60728
+ }
60729
+ let parsed = null;
60730
+ try {
60731
+ parsed = text5 ? JSON.parse(text5) : null;
60732
+ } catch {
60733
+ }
60734
+ if (!res.ok) {
60735
+ throw new JefriHttpError(parsed?.error ?? `${method} ${path3} failed with ${res.status}`, res.status, parsed);
60736
+ }
60737
+ return parsed;
60738
+ }
60739
+ // --- identity ------------------------------------------------------------
60740
+ /** /api/me answers `{ identity, online }`, NOT a bare Identity — unwrapping is
60741
+ * the caller's job and getting it wrong is silent: every field reads as
60742
+ * undefined and the object still looks like an Identity. Caught by running
60743
+ * this against the real hub; a stubbed test would have returned whatever
60744
+ * shape the stub was written to expect. */
60745
+ async whoami() {
60746
+ const body = await this.call("GET", "/api/me");
60747
+ const me = body?.identity ?? body;
60748
+ if (!me?.username) throw new Error("unexpected /api/me response shape");
60749
+ this.identity = me;
60750
+ return me;
60751
+ }
60752
+ // --- reads ---------------------------------------------------------------
60753
+ identities() {
60754
+ return this.call("GET", "/api/identities");
60755
+ }
60756
+ groups() {
60757
+ return this.call("GET", "/api/groups");
60758
+ }
60759
+ inbox(since) {
60760
+ return this.call("GET", "/api/inbox", void 0, { since });
60761
+ }
60762
+ search(query) {
60763
+ return this.call("GET", "/api/search", void 0, { q: query });
60764
+ }
60765
+ history(conversationId, before) {
60766
+ return this.call("GET", "/api/history", void 0, { conversationId, before });
60767
+ }
60768
+ // --- writes --------------------------------------------------------------
60769
+ /** Sends carry an idempotency key.
60770
+ *
60771
+ * The hub persists the key in the SAME transaction as the message, so a retry
60772
+ * is exactly-once rather than at-least-once. This matters far more for a
60773
+ * stateless client than a socket one: a timed-out or connection-reset POST
60774
+ * gives no evidence of whether the send landed, and the natural response — retry
60775
+ * — is precisely what produces duplicates during a deployment.
60776
+ *
60777
+ * Callers may pass their own key when a retry must reuse it; otherwise one is
60778
+ * minted per call. */
60779
+ message(to, content3, idempotencyKey) {
60780
+ return this.call("POST", "/api/messages", { to, content: content3 }, void 0, idem(idempotencyKey));
60781
+ }
60782
+ groupMessage(groupId, content3, idempotencyKey) {
60783
+ return this.call("POST", "/api/messages", { groupId, content: content3 }, void 0, idem(idempotencyKey));
60784
+ }
60785
+ /** DECLARE a status — a user-level choice that hits every connection the
60786
+ * identity holds. The returned status is the SETTLED one, which may differ
60787
+ * from the request if another lease outvoted it. */
60788
+ presence(status) {
60789
+ return this.call("POST", "/api/presence", { status });
60790
+ }
60791
+ /** KEEPALIVE — "still here", declaring nothing. This is the call for a timer.
60792
+ * Heartbeating through presence("online") instead is a declaration on a
60793
+ * timer, and it silently undoes an appear-offline chosen elsewhere.
60794
+ *
60795
+ * REJECTS (409, "signed off") when the identity has signed off: the beat was
60796
+ * refused and no lease exists, so the timer should STOP and the client must
60797
+ * declare a presence to resume. The refusal used to arrive as ok:true with a
60798
+ * full TTL — a lease lifetime that did not exist. */
60799
+ async heartbeat() {
60800
+ try {
60801
+ return await this.call("POST", "/api/presence", { keepalive: true });
60802
+ } catch (e) {
60803
+ if (e instanceof JefriHttpError && e.status === 409 && e.state === "signedOff")
60804
+ return { ok: false, state: "signedOff", status: String(e.body?.status ?? "offline"), expiresInMs: 0 };
60805
+ throw e;
60806
+ }
60807
+ }
60808
+ addFriend(to) {
60809
+ return this.call("POST", "/api/friends/request", { to });
60810
+ }
60811
+ /** Upload and deliver a file. The hub's raw-body route takes the bytes
60812
+ * directly, so there is no base64 round-trip. */
60813
+ async sendFile(target, fileName, mime, bytes, idempotencyKey) {
60814
+ const url = new URL(this.base + "/api/files");
60815
+ if (target.to) url.searchParams.set("to", target.to);
60816
+ if (target.groupId) url.searchParams.set("groupId", target.groupId);
60817
+ url.searchParams.set("fileName", fileName);
60818
+ if (target.caption) url.searchParams.set("caption", target.caption);
60819
+ const uploadTimeout = Math.max(this.timeoutMs, 3e4 + bytes.length / 20);
60820
+ const ac = new AbortController();
60821
+ const timer = setTimeout(() => ac.abort(), uploadTimeout);
60822
+ let res;
60823
+ try {
60824
+ res = await this.f(url.toString(), {
60825
+ method: "POST",
60826
+ signal: ac.signal,
60827
+ headers: {
60828
+ authorization: `Bearer ${this.token}`,
60829
+ "content-type": mime || "application/octet-stream",
60830
+ ...idem(idempotencyKey)
60831
+ },
60832
+ body: bytes
60833
+ });
60834
+ } catch (e) {
60835
+ clearTimeout(timer);
60836
+ if (e?.name === "AbortError") throw new Error(`file upload timed out after ${Math.round(uploadTimeout)}ms`);
60837
+ throw e;
60838
+ }
60839
+ let text5;
60840
+ try {
60841
+ text5 = await res.text();
60842
+ } catch (e) {
60843
+ if (e?.name === "AbortError") throw new Error(`file upload timed out after ${Math.round(uploadTimeout)}ms reading the response`);
60844
+ throw e;
60845
+ } finally {
60846
+ clearTimeout(timer);
60847
+ }
60848
+ if (!res.ok) {
60849
+ let msg = `upload failed with ${res.status}`;
60850
+ try {
60851
+ msg = JSON.parse(text5)?.error ?? msg;
60852
+ } catch {
60853
+ }
60854
+ throw new Error(msg);
60855
+ }
60856
+ return text5 ? JSON.parse(text5) : null;
60857
+ }
60858
+ // --- deliberately unsupported -------------------------------------------
60859
+ /** Realtime delivery needs a socket. A stateless caller polls /api/inbox. */
60860
+ on() {
60861
+ throw new UnsupportedOverHttp("Realtime events", "this client holds no socket; poll the inbox instead");
60862
+ }
60863
+ off() {
60864
+ throw new UnsupportedOverHttp("Realtime events", "this client holds no socket; poll the inbox instead");
60865
+ }
60866
+ /** E2E sends are excluded until key custody is durable — see the file header.
60867
+ * Throwing names the reason so the next person does not "fix" it by wiring the
60868
+ * call through and shipping unreadable ciphertext. */
60869
+ privateMessage() {
60870
+ throw new UnsupportedOverHttp("End-to-end encrypted send", "private keys are not durably held; use the stateful connector");
60871
+ }
60872
+ privateGroupMessage() {
60873
+ throw new UnsupportedOverHttp("End-to-end encrypted send", "private keys are not durably held; use the stateful connector");
60874
+ }
60875
+ /** Nothing to close — kept so callers can treat both clients alike. */
60876
+ close() {
60877
+ }
60878
+ };
60879
+
60652
60880
  // ../sdk/src/index.ts
60653
60881
  var subtle = webcrypto.subtle;
60654
60882
  var enc = new TextEncoder();
@@ -60659,6 +60887,9 @@ var b64 = (bytes) => Buffer.from(bytes instanceof Uint8Array ? bytes : new Uint8
60659
60887
  var fromB64 = (s) => new Uint8Array(Buffer.from(s, "base64"));
60660
60888
  var safeName = (s) => s.replace(/[^a-zA-Z0-9_.-]/g, "_");
60661
60889
  var canonicalJwk = (jwk) => JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
60890
+ function newDeclId() {
60891
+ return "d-" + randomUUID2();
60892
+ }
60662
60893
  var JefriAuthError = class extends Error {
60663
60894
  code = "ACP_AUTH_FAILED";
60664
60895
  constructor(message = "invalid or expired Jefri Chat token") {
@@ -60682,8 +60913,52 @@ var JefriClient = class _JefriClient {
60682
60913
  // heartbeat: did we get a pong since the last ping?
60683
60914
  hasConnected = false;
60684
60915
  // armed for auto-reconnect only after the hub `registered` us
60685
- lastPresence = "online";
60686
- // re-announce this on reconnect
60916
+ /** UNACKNOWLEDGED presence intent, or null. Set by presence(), cleared when
60917
+ * the hub's own presence_update for this identity confirms the declaration
60918
+ * landed. Null on reconnect means: send nothing and let the hub's persisted
60919
+ * answer stand.
60920
+ *
60921
+ * Acknowledgment is the point, and it is what the first fix missed: a value
60922
+ * kept after delivery means "ever declared", not "pending" — so a client
60923
+ * that once declared away kept re-asserting it on every reconnect for the
60924
+ * life of the process, stomping whatever a newer device had chosen since.
60925
+ * The same stale-overwrite, one level down. */
60926
+ pendingPresence = null;
60927
+ /** The id minted for the pending declaration. Its ECHO on a presence_update
60928
+ * is the only genuine acknowledgment — see notePresenceAck. */
60929
+ pendingDeclId = null;
60930
+ /** The self statusRev BASELINE captured when the pending declaration was made.
60931
+ * An ack must be CAUSALLY newer than this — the audit reproduced a delayed
60932
+ * rev-9 event spending intent declared at a rev-10 baseline, after which the
60933
+ * server had already moved on. Identity + status matching alone cannot order
60934
+ * events; the revision can. */
60935
+ pendingBaselineRev = 0;
60936
+ /** Highest revision seen for our own status, from registered and from our own
60937
+ * presence_update events. */
60938
+ selfStatusRev = 0;
60939
+ /** Would a reconnect re-send a presence frame? True only while a declaration
60940
+ * is UNACKNOWLEDGED. Exposed for tests: the wire behaviour needs a live hub,
60941
+ * but the rule is checkable without one. */
60942
+ wouldReassertPresence() {
60943
+ return this.pendingPresence !== null;
60944
+ }
60945
+ /** The acknowledgment that turns "pending" back into nothing: the hub
60946
+ * broadcasts presence_update to the declaring identity too, so our own
60947
+ * username carrying our pending status means the declaration LANDED — and
60948
+ * re-asserting it on a later reconnect would stomp whatever a newer device
60949
+ * declares afterwards. A METHOD, not inline in the socket handler, so tests
60950
+ * drive the rule production runs instead of a copy of it — a test-local
60951
+ * re-implementation of this exact logic passed with the real clearing
60952
+ * deleted, which is how the previous round's test enshrined the bug. */
60953
+ notePresenceAck(ev) {
60954
+ if (ev?.event !== "presence_update" || ev.username !== this.identity?.username) return;
60955
+ if (typeof ev.rev === "number" && ev.rev > this.selfStatusRev) this.selfStatusRev = ev.rev;
60956
+ if (this.pendingPresence === null) return;
60957
+ if (this.pendingDeclId !== null && ev.declId === this.pendingDeclId) {
60958
+ this.pendingPresence = null;
60959
+ this.pendingDeclId = null;
60960
+ }
60961
+ }
60687
60962
  clientInfo;
60688
60963
  // handshake, re-sent on reconnect
60689
60964
  /** Is the hub socket usable RIGHT NOW?
@@ -60778,7 +61053,8 @@ var JefriClient = class _JefriClient {
60778
61053
  this.ws.on("open", () => {
60779
61054
  this.startPing();
60780
61055
  try {
60781
- this.ws.send(JSON.stringify({ type: "presence", status: this.lastPresence }));
61056
+ if (this.pendingPresence !== null)
61057
+ this.ws.send(JSON.stringify({ type: "presence", status: this.pendingPresence, declId: this.pendingDeclId ?? void 0 }));
60782
61058
  if (this.clientInfo)
60783
61059
  this.ws.send(JSON.stringify({ type: "client_info", ...this.clientInfo }));
60784
61060
  } catch {
@@ -60807,12 +61083,15 @@ var JefriClient = class _JefriClient {
60807
61083
  }
60808
61084
  if (ev.event === "registered") {
60809
61085
  this.identity = ev.identity;
61086
+ const rev = ev.identity?.statusRev;
61087
+ if (typeof rev === "number" && rev > this.selfStatusRev) this.selfStatusRev = rev;
60810
61088
  this.hasConnected = true;
60811
61089
  this.reconnectAttempts = 0;
60812
61090
  }
60813
61091
  if (ev.event === "error" && /auth/i.test(ev.message ?? "")) {
60814
61092
  this.authFailed = true;
60815
61093
  }
61094
+ this.notePresenceAck(ev);
60816
61095
  this.emit(ev.event, ev);
60817
61096
  this.emit("*", ev);
60818
61097
  });
@@ -60901,8 +61180,10 @@ var JefriClient = class _JefriClient {
60901
61180
  }
60902
61181
  // --- actions -------------------------------------------------------------
60903
61182
  presence(status) {
60904
- this.lastPresence = status;
60905
- this.send({ type: "presence", status });
61183
+ this.pendingPresence = status;
61184
+ this.pendingBaselineRev = this.selfStatusRev;
61185
+ this.pendingDeclId = newDeclId();
61186
+ this.send({ type: "presence", status, declId: this.pendingDeclId });
60906
61187
  }
60907
61188
  message(to, content3) {
60908
61189
  this.send({ type: "message", to, content: content3 });
@@ -61232,6 +61513,29 @@ var JefriClient = class _JefriClient {
61232
61513
  };
61233
61514
  var serveDir = path2.join(os.homedir(), ".acp", "serve");
61234
61515
 
61516
+ // src/stateless-presence.ts
61517
+ var log = (...a) => console.error("[jefrichat-mcp-http]", ...a);
61518
+ async function statelessPresenceBringUp(acp) {
61519
+ try {
61520
+ const hb = await acp.heartbeat();
61521
+ if (hb.state === "signedOff") await acp.presence("online");
61522
+ } catch (e) {
61523
+ log("stateless presence bring-up failed (will retry on sweep):", e?.message ?? e);
61524
+ }
61525
+ }
61526
+ async function statelessSweepBeat(acp, pool, onAuthRevoked) {
61527
+ if (pool.closed || pool.hbSignedOff) return;
61528
+ try {
61529
+ const hb = await acp.heartbeat();
61530
+ if (hb.state === "signedOff") pool.hbSignedOff = true;
61531
+ } catch (e) {
61532
+ if (e instanceof JefriHttpError && (e.status === 401 || e.status === 403)) {
61533
+ onAuthRevoked();
61534
+ return;
61535
+ }
61536
+ }
61537
+ }
61538
+
61235
61539
  // src/conn-pool.ts
61236
61540
  function attachRef(c, id) {
61237
61541
  if (c.closed) return false;
@@ -61705,6 +62009,27 @@ var debateJobs = /* @__PURE__ */ new Map();
61705
62009
  var DEBATE_SEQ_TTL_MS = 10 * 60 * 1e3;
61706
62010
 
61707
62011
  // src/tools.ts
62012
+ var isStateless = (c) => c.stateless === true;
62013
+ async function confirmWrite(c, fire) {
62014
+ if (isStateless(c)) {
62015
+ try {
62016
+ await fire();
62017
+ return {};
62018
+ } catch (e2) {
62019
+ return { error: e2?.message ?? String(e2) };
62020
+ }
62021
+ }
62022
+ const err = waitFor(c, "error", () => true, 800);
62023
+ fire();
62024
+ const e = await err;
62025
+ return e ? { error: e.message } : {};
62026
+ }
62027
+ async function fetchHistory(c, convId) {
62028
+ if (isStateless(c)) return c.history(convId);
62029
+ const p = waitFor(c, "history", (e) => e.conversationId === convId, 4e3);
62030
+ c.history(convId);
62031
+ return p;
62032
+ }
61708
62033
  var MIME = {
61709
62034
  ".jpg": "image/jpeg",
61710
62035
  ".jpeg": "image/jpeg",
@@ -62040,7 +62365,7 @@ function saveWatermark() {
62040
62365
  }
62041
62366
  function registerAcpTools(server2, ctx) {
62042
62367
  const ENABLE_E2E = process.env.JEFRI_ENABLE_E2E === "true";
62043
- const e2eServer = ENABLE_E2E ? server2 : { registerTool: () => void 0 };
62368
+ const e2eServer = ENABLE_E2E && !ctx.stateless ? server2 : { registerTool: () => void 0 };
62044
62369
  const withClient = async (fn) => {
62045
62370
  try {
62046
62371
  const c = await ctx.ensureClient();
@@ -62158,10 +62483,8 @@ Use jefri_agent_apps with agent=<name> to see its live app + active state.`);
62158
62483
  }
62159
62484
  },
62160
62485
  async ({ to, text: text5 }) => withClient(async (c) => {
62161
- const err = waitFor(c, "error", () => true, 800);
62162
- c.message(to, text5);
62163
- const e = await err;
62164
- if (e) return fail(`Message rejected: ${e.message}`);
62486
+ const r = await confirmWrite(c, () => c.message(to, text5));
62487
+ if (r.error) return fail(`Message rejected: ${r.error}`);
62165
62488
  return ok3(`Sent to @${to}: "${text5}"`);
62166
62489
  })
62167
62490
  );
@@ -62176,8 +62499,9 @@ Use jefri_agent_apps with agent=<name> to see its live app + active state.`);
62176
62499
  }
62177
62500
  },
62178
62501
  async ({ to, text: text5 }) => withClient(async (c) => {
62179
- const err = waitFor(c, "error", () => true, 800);
62180
- await c.privateMessage(to, text5);
62502
+ const sc = c;
62503
+ const err = waitFor(sc, "error", () => true, 800);
62504
+ await sc.privateMessage(to, text5);
62181
62505
  const e = await err;
62182
62506
  if (e) return fail(`Private message rejected: ${e.message}`);
62183
62507
  return ok3(`Sent private E2E message to @${to}.`);
@@ -62234,8 +62558,9 @@ ${fp}`);
62234
62558
  } else {
62235
62559
  return fail("provide a local file `path` (or `dataUrl` + `fileName`)");
62236
62560
  }
62237
- await c.privateFile(to, name, mime, url);
62238
- if (caption) await c.privateMessage(to, caption);
62561
+ const sc = c;
62562
+ await sc.privateFile(to, name, mime, url);
62563
+ if (caption) await sc.privateMessage(to, caption);
62239
62564
  return ok3(`Sent private E2E file to @${to}.`);
62240
62565
  })
62241
62566
  );
@@ -62250,8 +62575,9 @@ ${fp}`);
62250
62575
  }
62251
62576
  },
62252
62577
  async ({ groupId, text: text5 }) => withClient(async (c) => {
62253
- const err = waitFor(c, "error", () => true, 800);
62254
- await c.privateGroupMessage(groupId, text5);
62578
+ const sc = c;
62579
+ const err = waitFor(sc, "error", () => true, 800);
62580
+ await sc.privateGroupMessage(groupId, text5);
62255
62581
  const e = await err;
62256
62582
  if (e) return fail(`Private group message rejected: ${e.message}`);
62257
62583
  return ok3(`Sent private E2E group message.`);
@@ -62366,9 +62692,9 @@ ${psCmd}
62366
62692
  return fail("provide a local file `path`, a `dataUrl` + `fileName`, or a public `fileUrl`");
62367
62693
  }
62368
62694
  const rawBytes = Buffer.from(url.split(",")[1] ?? "", "base64");
62369
- if (rawBytes.length > WS_SAFE_FILE_BYTES) {
62370
- if (rawBytes.length > MAX_UPLOAD_BYTES)
62371
- return fail(`${name} is ${MB(rawBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
62695
+ if (rawBytes.length > MAX_UPLOAD_BYTES)
62696
+ return fail(`${name} is ${MB(rawBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
62697
+ if (isStateless(c) || rawBytes.length > WS_SAFE_FILE_BYTES) {
62372
62698
  try {
62373
62699
  await uploadFileHttp(c, ctx.serverUrl, { to }, name, mime, rawBytes, caption);
62374
62700
  } catch (e2) {
@@ -62400,9 +62726,9 @@ ${psCmd}
62400
62726
  const known = mimeOf(fileName);
62401
62727
  const mime = known === "application/octet-stream" ? "text/plain" : known;
62402
62728
  const contentBytes = Buffer.from(content3, "utf8");
62403
- if (contentBytes.length > WS_SAFE_FILE_BYTES) {
62404
- if (contentBytes.length > MAX_UPLOAD_BYTES)
62405
- return fail(`That content is ${MB(contentBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
62729
+ if (contentBytes.length > MAX_UPLOAD_BYTES)
62730
+ return fail(`That content is ${MB(contentBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
62731
+ if (isStateless(c) || contentBytes.length > WS_SAFE_FILE_BYTES) {
62406
62732
  try {
62407
62733
  await uploadFileHttp(c, ctx.serverUrl, { to }, fileName, mime, contentBytes, caption);
62408
62734
  } catch (e2) {
@@ -62533,9 +62859,7 @@ ${psCmd}
62533
62859
  if (!from && !groupId) return fail("Give `from` (a username, for a DM) or `groupId` (for a group file).");
62534
62860
  const convId = groupId ? groupConversationId(groupId) : dmConversationId(c.identity.username, from);
62535
62861
  const where = groupId ? "this group" : `@${from}`;
62536
- const p = waitFor(c, "history", (e) => e.conversationId === convId, 4e3);
62537
- c.history(convId);
62538
- const res = await p;
62862
+ const res = await fetchHistory(c, convId);
62539
62863
  const files = (res?.messages ?? []).filter((m) => m.kind === "file");
62540
62864
  if (!files.length) return fail(`No files in ${where} yet.`);
62541
62865
  const match = fileName ? [...files].reverse().find((m) => m.fileName === fileName || m.fileName?.includes(fileName)) : files[files.length - 1];
@@ -62572,10 +62896,8 @@ It downloads the file to your machine (current folder unless you set a path). Fi
62572
62896
  inputSchema: { to: external_exports.string().describe("username to connect with") }
62573
62897
  },
62574
62898
  async ({ to }) => withClient(async (c) => {
62575
- const err = waitFor(c, "error", () => true, 800);
62576
- c.addFriend(to);
62577
- const e = await err;
62578
- if (e) return fail(`Request rejected: ${e.message}`);
62899
+ const r = await confirmWrite(c, () => c.addFriend(to));
62900
+ if (r.error) return fail(`Request rejected: ${r.error}`);
62579
62901
  return ok3(`\u{1F91D} Connection request sent to @${to}. They (or their owner) must approve before you can message.`);
62580
62902
  })
62581
62903
  );
@@ -62871,10 +63193,8 @@ Or just upload ${fname} from the agent's page in the web app.`
62871
63193
  }
62872
63194
  },
62873
63195
  async ({ groupId, text: text5 }) => withClient(async (c) => {
62874
- const err = waitFor(c, "error", () => true, 800);
62875
- c.groupMessage(groupId, text5);
62876
- const e = await err;
62877
- if (e) return fail(`Couldn't post to the group: ${e.message}`);
63196
+ const r = await confirmWrite(c, () => c.groupMessage(groupId, text5));
63197
+ if (r.error) return fail(`Couldn't post to the group: ${r.error}`);
62878
63198
  return ok3(`Posted to the group.`);
62879
63199
  })
62880
63200
  );
@@ -62934,9 +63254,9 @@ Or just upload ${fname} from the agent's page in the web app.`
62934
63254
  return fail("provide a local file `path`, a `dataUrl` + `fileName`, or a public `fileUrl`");
62935
63255
  }
62936
63256
  const rawBytes = Buffer.from(url.split(",")[1] ?? "", "base64");
62937
- if (rawBytes.length > WS_SAFE_FILE_BYTES) {
62938
- if (rawBytes.length > MAX_UPLOAD_BYTES)
62939
- return fail(`${name} is ${MB(rawBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
63257
+ if (rawBytes.length > MAX_UPLOAD_BYTES)
63258
+ return fail(`${name} is ${MB(rawBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
63259
+ if (isStateless(c) || rawBytes.length > WS_SAFE_FILE_BYTES) {
62940
63260
  try {
62941
63261
  await uploadFileHttp(c, ctx.serverUrl, { groupId }, name, mime, rawBytes, caption);
62942
63262
  } catch (e2) {
@@ -62967,9 +63287,7 @@ Or just upload ${fname} from the agent's page in the web app.`
62967
63287
  if (!other && !groupId) return fail("Give `with` (a username, for a DM) or `groupId` (for a group).");
62968
63288
  const convId = groupId ? groupConversationId(groupId) : dmConversationId(c.identity.username, other);
62969
63289
  const label = groupId ? "this group" : `@${other}`;
62970
- const p = waitFor(c, "history", (e) => e.conversationId === convId);
62971
- c.history(convId);
62972
- const res = await p;
63290
+ const res = await fetchHistory(c, convId);
62973
63291
  const msgs = (res?.messages ?? []).slice(-(limit ?? 20));
62974
63292
  if (!msgs.length) return ok3(`No messages yet in ${label}.`);
62975
63293
  const lines = await Promise.all(msgs.map(async (m) => {
@@ -63000,10 +63318,14 @@ Or just upload ${fname} from the agent's page in the web app.`
63000
63318
  inputSchema: { query: external_exports.string() }
63001
63319
  },
63002
63320
  async ({ query }) => withClient(async (c) => {
63003
- const res = waitFor(c, "search_results", () => true);
63004
- c.search(query);
63005
- const e = await res;
63006
- const results = e?.results ?? [];
63321
+ let results;
63322
+ if (isStateless(c)) {
63323
+ results = (await c.search(query))?.results ?? [];
63324
+ } else {
63325
+ const res = waitFor(c, "search_results", () => true);
63326
+ c.search(query);
63327
+ results = (await res)?.results ?? [];
63328
+ }
63007
63329
  if (!results.length) return ok3(`No agents matched "${query}".`);
63008
63330
  return ok3(
63009
63331
  results.map((r) => `\u2022 @${r.username} \u2014 ${r.displayName} (${r.status})${r.owner ? ` \xB7 by @${r.owner}` : ""} \xB7 ${r.tags.join(", ")}`).join("\n")
@@ -63020,7 +63342,8 @@ Or just upload ${fname} from the agent's page in the web app.`
63020
63342
  }
63021
63343
  },
63022
63344
  async ({ status }) => withClient(async (c) => {
63023
- c.presence(status);
63345
+ const r = await confirmWrite(c, () => c.presence(status));
63346
+ if (r.error) return fail(`Couldn't set status: ${r.error}`);
63024
63347
  return ok3(`Status set to ${status}.`);
63025
63348
  })
63026
63349
  );
@@ -63212,7 +63535,8 @@ function sweepIdleSessions(map3) {
63212
63535
  // src/http.ts
63213
63536
  var HUB = process.env.JEFRI_SERVER ?? "http://localhost:4000";
63214
63537
  var PORT = Number(process.env.MCP_HTTP_PORT ?? 4001);
63215
- var log = (...a) => console.error("[jefrichat-mcp-http]", ...a);
63538
+ var STATELESS = process.env.JEFRI_STATELESS === "true";
63539
+ var log2 = (...a) => console.error("[jefrichat-mcp-http]", ...a);
63216
63540
  var sessions = /* @__PURE__ */ new Map();
63217
63541
  var HOST_FRESH_MS = Number(process.env.MCP_HOST_FRESH_MS ?? 10 * 60 * 1e3);
63218
63542
  var hubPool = createKeyedPool();
@@ -63247,7 +63571,7 @@ function closeSession(sid, reason) {
63247
63571
  if (detachRef(pooled, sid)) teardownPool(s.poolKey, pooled, "last session gone");
63248
63572
  else if (s.host) announcePoolHosts(s.poolKey, pooled);
63249
63573
  }
63250
- log(`session ${sid.slice(0, 8)}\u2026 closed (${reason}) (${sessions.size} active)`);
63574
+ log2(`session ${sid.slice(0, 8)}\u2026 closed (${reason}) (${sessions.size} active)`);
63251
63575
  }
63252
63576
  function teardownPool(poolKey, pooled, reason) {
63253
63577
  pooled.closed = true;
@@ -63256,9 +63580,10 @@ function teardownPool(poolKey, pooled, reason) {
63256
63580
  pooled.acp.close();
63257
63581
  } catch {
63258
63582
  }
63259
- log(`hub connection ${poolKey.slice(0, 8)}\u2026 closed (${reason})`);
63583
+ log2(`hub connection ${poolKey.slice(0, 8)}\u2026 closed (${reason})`);
63260
63584
  }
63261
63585
  function announcePoolHosts(poolKey, pooled) {
63586
+ if (STATELESS) return;
63262
63587
  const joined = aggregateFreshHosts(sessions.values(), poolKey, Date.now(), HOST_FRESH_MS);
63263
63588
  if (joined === pooled.lastHosts) return;
63264
63589
  pooled.lastHosts = joined;
@@ -63273,6 +63598,12 @@ function closePool(poolKey, reason) {
63273
63598
  }
63274
63599
  async function acquirePooledConn(token, poolKey) {
63275
63600
  return hubPool.acquire(poolKey, async () => {
63601
+ if (STATELESS) {
63602
+ const acp2 = new JefriHttpClient({ server: HUB, token });
63603
+ await acp2.whoami();
63604
+ await statelessPresenceBringUp(acp2);
63605
+ return { acp: acp2, inbox: [], refs: /* @__PURE__ */ new Set(), closed: false };
63606
+ }
63276
63607
  const acp = await JefriClient.connect({ server: HUB, token, status: "online" });
63277
63608
  const inbox = [];
63278
63609
  if (acp.identity) attachInbox(acp, inbox, acp.identity.username);
@@ -63299,7 +63630,10 @@ setInterval(() => {
63299
63630
  if (seenPools.has(s.poolKey)) continue;
63300
63631
  seenPools.add(s.poolKey);
63301
63632
  const pooled = hubPool.get(s.poolKey);
63302
- if (pooled) announcePoolHosts(s.poolKey, pooled);
63633
+ if (!pooled) continue;
63634
+ announcePoolHosts(s.poolKey, pooled);
63635
+ if (STATELESS)
63636
+ void statelessSweepBeat(pooled.acp, pooled, () => closePool(s.poolKey, "auth revoked"));
63303
63637
  }
63304
63638
  for (const [ip, e] of initHits) {
63305
63639
  if (now > e.resetAt) initHits.delete(ip);
@@ -63358,6 +63692,7 @@ var health = (_req, res) => res.status(draining ? 503 : 200).json({
63358
63692
  draining,
63359
63693
  service: "jefrichat-mcp-http",
63360
63694
  hub: HUB,
63695
+ stateless: STATELESS,
63361
63696
  sessions: sessions.size
63362
63697
  });
63363
63698
  app.get("/health", health);
@@ -63397,6 +63732,14 @@ app.post("/mcp", async (req, res) => {
63397
63732
  }
63398
63733
  return;
63399
63734
  }
63735
+ if (draining && !sessionId && isInitializeRequest(req.body)) {
63736
+ res.setHeader("retry-after", "1");
63737
+ return res.status(503).json({
63738
+ jsonrpc: "2.0",
63739
+ error: { code: -32e3, message: "server is shutting down \u2014 retry; another instance will accept this session" },
63740
+ id: null
63741
+ });
63742
+ }
63400
63743
  if (!sessionId && isInitializeRequest(req.body)) {
63401
63744
  if (initRateLimited(req)) {
63402
63745
  res.status(429).json({
@@ -63420,7 +63763,7 @@ app.post("/mcp", async (req, res) => {
63420
63763
  try {
63421
63764
  pooled = await acquirePooledConn(token, poolKey);
63422
63765
  } catch (e) {
63423
- if (e instanceof JefriAuthError || e?.code === "ACP_AUTH_FAILED") {
63766
+ if (e instanceof JefriAuthError || e?.code === "ACP_AUTH_FAILED" || e instanceof JefriHttpError && (e.status === 401 || e.status === 403)) {
63424
63767
  res.status(401).json({
63425
63768
  jsonrpc: "2.0",
63426
63769
  error: {
@@ -63434,6 +63777,10 @@ app.post("/mcp", async (req, res) => {
63434
63777
  res.status(502).json({ jsonrpc: "2.0", error: { code: -32002, message: `Hub unreachable: ${e?.message ?? e}` }, id: null });
63435
63778
  return;
63436
63779
  }
63780
+ if (STATELESS && pooled.hbSignedOff) {
63781
+ pooled.hbSignedOff = false;
63782
+ void statelessPresenceBringUp(pooled.acp);
63783
+ }
63437
63784
  const acp = pooled.acp;
63438
63785
  if (!acp.identity) {
63439
63786
  closePool(poolKey, "no identity");
@@ -63445,7 +63792,7 @@ app.post("/mcp", async (req, res) => {
63445
63792
  return;
63446
63793
  }
63447
63794
  const mcp = new McpServer({ name: "jefrichat", version: "0.1.0" });
63448
- registerAcpTools(mcp, { ensureClient: async () => acp, inbox: pooled.inbox, serverUrl: HUB, local: false });
63795
+ registerAcpTools(mcp, { ensureClient: async () => acp, inbox: pooled.inbox, serverUrl: HUB, local: false, stateless: STATELESS });
63449
63796
  let mySid;
63450
63797
  mcp.server.oninitialized = () => {
63451
63798
  try {
@@ -63457,14 +63804,14 @@ app.post("/mcp", async (req, res) => {
63457
63804
  }
63458
63805
  };
63459
63806
  const transport = new StreamableHTTPServerTransport({
63460
- sessionIdGenerator: () => randomUUID(),
63807
+ sessionIdGenerator: () => randomUUID3(),
63461
63808
  onsessioninitialized: (sid) => {
63462
63809
  if (pooled.closed) {
63463
63810
  try {
63464
63811
  transport.close?.();
63465
63812
  } catch {
63466
63813
  }
63467
- log(`session ${sid.slice(0, 8)}\u2026 refused (connection torn down during init)`);
63814
+ log2(`session ${sid.slice(0, 8)}\u2026 refused (connection torn down during init)`);
63468
63815
  return;
63469
63816
  }
63470
63817
  const meName = acp.identity.username;
@@ -63489,19 +63836,19 @@ app.post("/mcp", async (req, res) => {
63489
63836
  transport.close?.();
63490
63837
  } catch {
63491
63838
  }
63492
- log(`session ${sid.slice(0, 8)}\u2026 refused (connection torn down during init)`);
63839
+ log2(`session ${sid.slice(0, 8)}\u2026 refused (connection torn down during init)`);
63493
63840
  return;
63494
63841
  }
63495
63842
  sessions.set(sid, { transport, acp, lastActive: Date.now(), tokenHash: sha256(token), inflight: 0, poolKey });
63496
63843
  mySid = sid;
63497
- log(`session ${sid.slice(0, 8)}\u2026 open as @${acp.identity.username} (${sessions.size} active, ${pooled.refs.size} on this connection)`);
63844
+ log2(`session ${sid.slice(0, 8)}\u2026 open as @${acp.identity.username} (${sessions.size} active, ${pooled.refs.size} on this connection)`);
63498
63845
  }
63499
63846
  });
63500
63847
  transport.onclose = () => {
63501
63848
  const sid = transport.sessionId;
63502
63849
  if (sid) closeSession(sid, "client closed");
63503
63850
  };
63504
- const reservation = "init:" + randomUUID();
63851
+ const reservation = "init:" + randomUUID3();
63505
63852
  pooled.refs.add(reservation);
63506
63853
  try {
63507
63854
  await mcp.connect(transport);
package/dist/index.js CHANGED
@@ -26890,7 +26890,7 @@ var wrapper_default = import_websocket.default;
26890
26890
  import fs from "node:fs";
26891
26891
  import os from "node:os";
26892
26892
  import path2 from "node:path";
26893
- import { webcrypto } from "node:crypto";
26893
+ import { webcrypto, randomUUID } from "node:crypto";
26894
26894
 
26895
26895
  // ../../node_modules/.pnpm/bail@2.0.2/node_modules/bail/index.js
26896
26896
  function bail(error2) {
@@ -38625,6 +38625,9 @@ var b64 = (bytes) => Buffer.from(bytes instanceof Uint8Array ? bytes : new Uint8
38625
38625
  var fromB64 = (s) => new Uint8Array(Buffer.from(s, "base64"));
38626
38626
  var safeName = (s) => s.replace(/[^a-zA-Z0-9_.-]/g, "_");
38627
38627
  var canonicalJwk = (jwk) => JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
38628
+ function newDeclId() {
38629
+ return "d-" + randomUUID();
38630
+ }
38628
38631
  var JefriAuthError = class extends Error {
38629
38632
  code = "ACP_AUTH_FAILED";
38630
38633
  constructor(message = "invalid or expired Jefri Chat token") {
@@ -38648,8 +38651,52 @@ var JefriClient = class _JefriClient {
38648
38651
  // heartbeat: did we get a pong since the last ping?
38649
38652
  hasConnected = false;
38650
38653
  // armed for auto-reconnect only after the hub `registered` us
38651
- lastPresence = "online";
38652
- // re-announce this on reconnect
38654
+ /** UNACKNOWLEDGED presence intent, or null. Set by presence(), cleared when
38655
+ * the hub's own presence_update for this identity confirms the declaration
38656
+ * landed. Null on reconnect means: send nothing and let the hub's persisted
38657
+ * answer stand.
38658
+ *
38659
+ * Acknowledgment is the point, and it is what the first fix missed: a value
38660
+ * kept after delivery means "ever declared", not "pending" — so a client
38661
+ * that once declared away kept re-asserting it on every reconnect for the
38662
+ * life of the process, stomping whatever a newer device had chosen since.
38663
+ * The same stale-overwrite, one level down. */
38664
+ pendingPresence = null;
38665
+ /** The id minted for the pending declaration. Its ECHO on a presence_update
38666
+ * is the only genuine acknowledgment — see notePresenceAck. */
38667
+ pendingDeclId = null;
38668
+ /** The self statusRev BASELINE captured when the pending declaration was made.
38669
+ * An ack must be CAUSALLY newer than this — the audit reproduced a delayed
38670
+ * rev-9 event spending intent declared at a rev-10 baseline, after which the
38671
+ * server had already moved on. Identity + status matching alone cannot order
38672
+ * events; the revision can. */
38673
+ pendingBaselineRev = 0;
38674
+ /** Highest revision seen for our own status, from registered and from our own
38675
+ * presence_update events. */
38676
+ selfStatusRev = 0;
38677
+ /** Would a reconnect re-send a presence frame? True only while a declaration
38678
+ * is UNACKNOWLEDGED. Exposed for tests: the wire behaviour needs a live hub,
38679
+ * but the rule is checkable without one. */
38680
+ wouldReassertPresence() {
38681
+ return this.pendingPresence !== null;
38682
+ }
38683
+ /** The acknowledgment that turns "pending" back into nothing: the hub
38684
+ * broadcasts presence_update to the declaring identity too, so our own
38685
+ * username carrying our pending status means the declaration LANDED — and
38686
+ * re-asserting it on a later reconnect would stomp whatever a newer device
38687
+ * declares afterwards. A METHOD, not inline in the socket handler, so tests
38688
+ * drive the rule production runs instead of a copy of it — a test-local
38689
+ * re-implementation of this exact logic passed with the real clearing
38690
+ * deleted, which is how the previous round's test enshrined the bug. */
38691
+ notePresenceAck(ev) {
38692
+ if (ev?.event !== "presence_update" || ev.username !== this.identity?.username) return;
38693
+ if (typeof ev.rev === "number" && ev.rev > this.selfStatusRev) this.selfStatusRev = ev.rev;
38694
+ if (this.pendingPresence === null) return;
38695
+ if (this.pendingDeclId !== null && ev.declId === this.pendingDeclId) {
38696
+ this.pendingPresence = null;
38697
+ this.pendingDeclId = null;
38698
+ }
38699
+ }
38653
38700
  clientInfo;
38654
38701
  // handshake, re-sent on reconnect
38655
38702
  /** Is the hub socket usable RIGHT NOW?
@@ -38744,7 +38791,8 @@ var JefriClient = class _JefriClient {
38744
38791
  this.ws.on("open", () => {
38745
38792
  this.startPing();
38746
38793
  try {
38747
- this.ws.send(JSON.stringify({ type: "presence", status: this.lastPresence }));
38794
+ if (this.pendingPresence !== null)
38795
+ this.ws.send(JSON.stringify({ type: "presence", status: this.pendingPresence, declId: this.pendingDeclId ?? void 0 }));
38748
38796
  if (this.clientInfo)
38749
38797
  this.ws.send(JSON.stringify({ type: "client_info", ...this.clientInfo }));
38750
38798
  } catch {
@@ -38773,12 +38821,15 @@ var JefriClient = class _JefriClient {
38773
38821
  }
38774
38822
  if (ev.event === "registered") {
38775
38823
  this.identity = ev.identity;
38824
+ const rev = ev.identity?.statusRev;
38825
+ if (typeof rev === "number" && rev > this.selfStatusRev) this.selfStatusRev = rev;
38776
38826
  this.hasConnected = true;
38777
38827
  this.reconnectAttempts = 0;
38778
38828
  }
38779
38829
  if (ev.event === "error" && /auth/i.test(ev.message ?? "")) {
38780
38830
  this.authFailed = true;
38781
38831
  }
38832
+ this.notePresenceAck(ev);
38782
38833
  this.emit(ev.event, ev);
38783
38834
  this.emit("*", ev);
38784
38835
  });
@@ -38867,8 +38918,10 @@ var JefriClient = class _JefriClient {
38867
38918
  }
38868
38919
  // --- actions -------------------------------------------------------------
38869
38920
  presence(status) {
38870
- this.lastPresence = status;
38871
- this.send({ type: "presence", status });
38921
+ this.pendingPresence = status;
38922
+ this.pendingBaselineRev = this.selfStatusRev;
38923
+ this.pendingDeclId = newDeclId();
38924
+ this.send({ type: "presence", status, declId: this.pendingDeclId });
38872
38925
  }
38873
38926
  message(to, content3) {
38874
38927
  this.send({ type: "message", to, content: content3 });
@@ -40488,6 +40541,27 @@ async function runOnceForMessage(m) {
40488
40541
  }
40489
40542
 
40490
40543
  // src/tools.ts
40544
+ var isStateless = (c2) => c2.stateless === true;
40545
+ async function confirmWrite(c2, fire) {
40546
+ if (isStateless(c2)) {
40547
+ try {
40548
+ await fire();
40549
+ return {};
40550
+ } catch (e2) {
40551
+ return { error: e2?.message ?? String(e2) };
40552
+ }
40553
+ }
40554
+ const err = waitFor(c2, "error", () => true, 800);
40555
+ fire();
40556
+ const e = await err;
40557
+ return e ? { error: e.message } : {};
40558
+ }
40559
+ async function fetchHistory2(c2, convId) {
40560
+ if (isStateless(c2)) return c2.history(convId);
40561
+ const p = waitFor(c2, "history", (e) => e.conversationId === convId, 4e3);
40562
+ c2.history(convId);
40563
+ return p;
40564
+ }
40491
40565
  var MIME = {
40492
40566
  ".jpg": "image/jpeg",
40493
40567
  ".jpeg": "image/jpeg",
@@ -40826,7 +40900,7 @@ function saveWatermark() {
40826
40900
  }
40827
40901
  function registerAcpTools(server2, ctx) {
40828
40902
  const ENABLE_E2E = process.env.JEFRI_ENABLE_E2E === "true";
40829
- const e2eServer = ENABLE_E2E ? server2 : { registerTool: () => void 0 };
40903
+ const e2eServer = ENABLE_E2E && !ctx.stateless ? server2 : { registerTool: () => void 0 };
40830
40904
  const withClient = async (fn) => {
40831
40905
  try {
40832
40906
  const c2 = await ctx.ensureClient();
@@ -40944,10 +41018,8 @@ Use jefri_agent_apps with agent=<name> to see its live app + active state.`);
40944
41018
  }
40945
41019
  },
40946
41020
  async ({ to, text: text5 }) => withClient(async (c2) => {
40947
- const err = waitFor(c2, "error", () => true, 800);
40948
- c2.message(to, text5);
40949
- const e = await err;
40950
- if (e) return fail(`Message rejected: ${e.message}`);
41021
+ const r = await confirmWrite(c2, () => c2.message(to, text5));
41022
+ if (r.error) return fail(`Message rejected: ${r.error}`);
40951
41023
  return ok3(`Sent to @${to}: "${text5}"`);
40952
41024
  })
40953
41025
  );
@@ -40962,8 +41034,9 @@ Use jefri_agent_apps with agent=<name> to see its live app + active state.`);
40962
41034
  }
40963
41035
  },
40964
41036
  async ({ to, text: text5 }) => withClient(async (c2) => {
40965
- const err = waitFor(c2, "error", () => true, 800);
40966
- await c2.privateMessage(to, text5);
41037
+ const sc = c2;
41038
+ const err = waitFor(sc, "error", () => true, 800);
41039
+ await sc.privateMessage(to, text5);
40967
41040
  const e = await err;
40968
41041
  if (e) return fail(`Private message rejected: ${e.message}`);
40969
41042
  return ok3(`Sent private E2E message to @${to}.`);
@@ -41020,8 +41093,9 @@ ${fp}`);
41020
41093
  } else {
41021
41094
  return fail("provide a local file `path` (or `dataUrl` + `fileName`)");
41022
41095
  }
41023
- await c2.privateFile(to, name, mime, url);
41024
- if (caption) await c2.privateMessage(to, caption);
41096
+ const sc = c2;
41097
+ await sc.privateFile(to, name, mime, url);
41098
+ if (caption) await sc.privateMessage(to, caption);
41025
41099
  return ok3(`Sent private E2E file to @${to}.`);
41026
41100
  })
41027
41101
  );
@@ -41036,8 +41110,9 @@ ${fp}`);
41036
41110
  }
41037
41111
  },
41038
41112
  async ({ groupId, text: text5 }) => withClient(async (c2) => {
41039
- const err = waitFor(c2, "error", () => true, 800);
41040
- await c2.privateGroupMessage(groupId, text5);
41113
+ const sc = c2;
41114
+ const err = waitFor(sc, "error", () => true, 800);
41115
+ await sc.privateGroupMessage(groupId, text5);
41041
41116
  const e = await err;
41042
41117
  if (e) return fail(`Private group message rejected: ${e.message}`);
41043
41118
  return ok3(`Sent private E2E group message.`);
@@ -41152,9 +41227,9 @@ ${psCmd}
41152
41227
  return fail("provide a local file `path`, a `dataUrl` + `fileName`, or a public `fileUrl`");
41153
41228
  }
41154
41229
  const rawBytes = Buffer.from(url.split(",")[1] ?? "", "base64");
41155
- if (rawBytes.length > WS_SAFE_FILE_BYTES) {
41156
- if (rawBytes.length > MAX_UPLOAD_BYTES)
41157
- return fail(`${name} is ${MB(rawBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
41230
+ if (rawBytes.length > MAX_UPLOAD_BYTES)
41231
+ return fail(`${name} is ${MB(rawBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
41232
+ if (isStateless(c2) || rawBytes.length > WS_SAFE_FILE_BYTES) {
41158
41233
  try {
41159
41234
  await uploadFileHttp(c2, ctx.serverUrl, { to }, name, mime, rawBytes, caption);
41160
41235
  } catch (e2) {
@@ -41186,9 +41261,9 @@ ${psCmd}
41186
41261
  const known = mimeOf(fileName);
41187
41262
  const mime = known === "application/octet-stream" ? "text/plain" : known;
41188
41263
  const contentBytes = Buffer.from(content3, "utf8");
41189
- if (contentBytes.length > WS_SAFE_FILE_BYTES) {
41190
- if (contentBytes.length > MAX_UPLOAD_BYTES)
41191
- return fail(`That content is ${MB(contentBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
41264
+ if (contentBytes.length > MAX_UPLOAD_BYTES)
41265
+ return fail(`That content is ${MB(contentBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
41266
+ if (isStateless(c2) || contentBytes.length > WS_SAFE_FILE_BYTES) {
41192
41267
  try {
41193
41268
  await uploadFileHttp(c2, ctx.serverUrl, { to }, fileName, mime, contentBytes, caption);
41194
41269
  } catch (e2) {
@@ -41319,9 +41394,7 @@ ${psCmd}
41319
41394
  if (!from && !groupId) return fail("Give `from` (a username, for a DM) or `groupId` (for a group file).");
41320
41395
  const convId = groupId ? groupConversationId(groupId) : dmConversationId(c2.identity.username, from);
41321
41396
  const where = groupId ? "this group" : `@${from}`;
41322
- const p = waitFor(c2, "history", (e) => e.conversationId === convId, 4e3);
41323
- c2.history(convId);
41324
- const res = await p;
41397
+ const res = await fetchHistory2(c2, convId);
41325
41398
  const files = (res?.messages ?? []).filter((m) => m.kind === "file");
41326
41399
  if (!files.length) return fail(`No files in ${where} yet.`);
41327
41400
  const match = fileName ? [...files].reverse().find((m) => m.fileName === fileName || m.fileName?.includes(fileName)) : files[files.length - 1];
@@ -41358,10 +41431,8 @@ It downloads the file to your machine (current folder unless you set a path). Fi
41358
41431
  inputSchema: { to: external_exports.string().describe("username to connect with") }
41359
41432
  },
41360
41433
  async ({ to }) => withClient(async (c2) => {
41361
- const err = waitFor(c2, "error", () => true, 800);
41362
- c2.addFriend(to);
41363
- const e = await err;
41364
- if (e) return fail(`Request rejected: ${e.message}`);
41434
+ const r = await confirmWrite(c2, () => c2.addFriend(to));
41435
+ if (r.error) return fail(`Request rejected: ${r.error}`);
41365
41436
  return ok3(`\u{1F91D} Connection request sent to @${to}. They (or their owner) must approve before you can message.`);
41366
41437
  })
41367
41438
  );
@@ -41657,10 +41728,8 @@ Or just upload ${fname} from the agent's page in the web app.`
41657
41728
  }
41658
41729
  },
41659
41730
  async ({ groupId, text: text5 }) => withClient(async (c2) => {
41660
- const err = waitFor(c2, "error", () => true, 800);
41661
- c2.groupMessage(groupId, text5);
41662
- const e = await err;
41663
- if (e) return fail(`Couldn't post to the group: ${e.message}`);
41731
+ const r = await confirmWrite(c2, () => c2.groupMessage(groupId, text5));
41732
+ if (r.error) return fail(`Couldn't post to the group: ${r.error}`);
41664
41733
  return ok3(`Posted to the group.`);
41665
41734
  })
41666
41735
  );
@@ -41720,9 +41789,9 @@ Or just upload ${fname} from the agent's page in the web app.`
41720
41789
  return fail("provide a local file `path`, a `dataUrl` + `fileName`, or a public `fileUrl`");
41721
41790
  }
41722
41791
  const rawBytes = Buffer.from(url.split(",")[1] ?? "", "base64");
41723
- if (rawBytes.length > WS_SAFE_FILE_BYTES) {
41724
- if (rawBytes.length > MAX_UPLOAD_BYTES)
41725
- return fail(`${name} is ${MB(rawBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
41792
+ if (rawBytes.length > MAX_UPLOAD_BYTES)
41793
+ return fail(`${name} is ${MB(rawBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
41794
+ if (isStateless(c2) || rawBytes.length > WS_SAFE_FILE_BYTES) {
41726
41795
  try {
41727
41796
  await uploadFileHttp(c2, ctx.serverUrl, { groupId }, name, mime, rawBytes, caption);
41728
41797
  } catch (e2) {
@@ -41753,9 +41822,7 @@ Or just upload ${fname} from the agent's page in the web app.`
41753
41822
  if (!other && !groupId) return fail("Give `with` (a username, for a DM) or `groupId` (for a group).");
41754
41823
  const convId = groupId ? groupConversationId(groupId) : dmConversationId(c2.identity.username, other);
41755
41824
  const label = groupId ? "this group" : `@${other}`;
41756
- const p = waitFor(c2, "history", (e) => e.conversationId === convId);
41757
- c2.history(convId);
41758
- const res = await p;
41825
+ const res = await fetchHistory2(c2, convId);
41759
41826
  const msgs = (res?.messages ?? []).slice(-(limit ?? 20));
41760
41827
  if (!msgs.length) return ok3(`No messages yet in ${label}.`);
41761
41828
  const lines = await Promise.all(msgs.map(async (m) => {
@@ -41786,10 +41853,14 @@ Or just upload ${fname} from the agent's page in the web app.`
41786
41853
  inputSchema: { query: external_exports.string() }
41787
41854
  },
41788
41855
  async ({ query }) => withClient(async (c2) => {
41789
- const res = waitFor(c2, "search_results", () => true);
41790
- c2.search(query);
41791
- const e = await res;
41792
- const results = e?.results ?? [];
41856
+ let results;
41857
+ if (isStateless(c2)) {
41858
+ results = (await c2.search(query))?.results ?? [];
41859
+ } else {
41860
+ const res = waitFor(c2, "search_results", () => true);
41861
+ c2.search(query);
41862
+ results = (await res)?.results ?? [];
41863
+ }
41793
41864
  if (!results.length) return ok3(`No agents matched "${query}".`);
41794
41865
  return ok3(
41795
41866
  results.map((r) => `\u2022 @${r.username} \u2014 ${r.displayName} (${r.status})${r.owner ? ` \xB7 by @${r.owner}` : ""} \xB7 ${r.tags.join(", ")}`).join("\n")
@@ -41806,7 +41877,8 @@ Or just upload ${fname} from the agent's page in the web app.`
41806
41877
  }
41807
41878
  },
41808
41879
  async ({ status }) => withClient(async (c2) => {
41809
- c2.presence(status);
41880
+ const r = await confirmWrite(c2, () => c2.presence(status));
41881
+ if (r.error) return fail(`Couldn't set status: ${r.error}`);
41810
41882
  return ok3(`Status set to ${status}.`);
41811
41883
  })
41812
41884
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jefrichat-mcp",
3
- "version": "0.48.8",
3
+ "version": "0.48.10",
4
4
  "description": "Jefri Chat connector — join the Jefri Chat network (WhatsApp for AI agents) from any MCP client (Claude, Codex, Cursor, …).",
5
5
  "type": "module",
6
6
  "bin": {