@gorlitzer-labs/apiary 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ import {
6
6
  roomEmoji,
7
7
  saveConfig,
8
8
  saveRoomSession
9
- } from "./chunk-LOONM4G3.js";
9
+ } from "./chunk-3UQBSELW.js";
10
10
  import {
11
11
  extractToken,
12
12
  tmuxCapturePane,
@@ -2018,4 +2018,4 @@ export {
2018
2018
  HEADLESS_READY,
2019
2019
  join
2020
2020
  };
2021
- //# sourceMappingURL=chunk-LNSDH2V5.js.map
2021
+ //# sourceMappingURL=chunk-74XU7HHN.js.map
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  RemoteRoomDataSource,
3
+ RoomConnectionError,
3
4
  buildCatchUpLines
4
- } from "./chunk-WJD5EKO6.js";
5
+ } from "./chunk-Z4J6TRJY.js";
5
6
  import {
6
7
  extractToken,
7
8
  tmuxCapturePane,
@@ -437,28 +438,349 @@ function describeOutcome(participant, until, outcome) {
437
438
  }
438
439
  }
439
440
 
441
+ // src/cli/rejoin.ts
442
+ import { existsSync, readFileSync } from "fs";
443
+ import { homedir } from "os";
444
+ import { join } from "path";
445
+ function readLocalRoomSession(roomName) {
446
+ if (!/^[A-Za-z0-9._-]+$/.test(roomName) || roomName.includes("..")) return null;
447
+ const path = join(homedir(), ".apiary", "sessions", `room_${roomName}.json`);
448
+ if (!existsSync(path)) return null;
449
+ try {
450
+ const data = JSON.parse(readFileSync(path, "utf-8"));
451
+ const str = (v) => typeof v === "string" && v ? v : void 0;
452
+ return {
453
+ serverUrl: str(data.serverUrl),
454
+ publicUrl: str(data.publicUrl),
455
+ adminToken: str(data.adminToken),
456
+ memberToken: str(data.memberToken)
457
+ };
458
+ } catch {
459
+ return null;
460
+ }
461
+ }
462
+ function isLoopbackUrl(url) {
463
+ try {
464
+ const host = new URL(url).hostname;
465
+ return host === "127.0.0.1" || host === "localhost";
466
+ } catch {
467
+ return false;
468
+ }
469
+ }
470
+ function sameOrigin(a, b) {
471
+ try {
472
+ return new URL(a).origin === new URL(b).origin;
473
+ } catch {
474
+ return a === b;
475
+ }
476
+ }
477
+ function localTierOf(shareToken, session) {
478
+ if (!shareToken || !session) return void 0;
479
+ if (session.adminToken === shareToken) return "admin";
480
+ if (session.memberToken === shareToken) return "member";
481
+ return void 0;
482
+ }
483
+ var RoomRejoiner = class {
484
+ constructor(room, deps) {
485
+ this.room = room;
486
+ this.deps = deps;
487
+ this.fetchFn = deps.fetch ?? ((...args) => fetch(...args));
488
+ this.now = deps.now ?? Date.now;
489
+ this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
490
+ this.readSession = deps.readRoomSession ?? readLocalRoomSession;
491
+ }
492
+ inflight = null;
493
+ lost = null;
494
+ failures = 0;
495
+ cooldownUntil = 0;
496
+ lastRejoinAt = Number.NEGATIVE_INFINITY;
497
+ lastProbeAt = Number.NEGATIVE_INFINITY;
498
+ fetchFn;
499
+ now;
500
+ sleep;
501
+ readSession;
502
+ /** Why the room is lost for good, if it is. */
503
+ get lostError() {
504
+ return this.lost;
505
+ }
506
+ // ── SessionRecovery (RemoteRoomDataSource) ─────────────────────────────
507
+ async sessionInvalid(usedToken) {
508
+ if (usedToken !== void 0 && usedToken !== this.room.sessionToken) return;
509
+ await this.rejoin("session token rejected (401)");
510
+ }
511
+ async unreachable() {
512
+ if (this.lost || this.room.removedBy) return false;
513
+ const now = this.now();
514
+ if (now - this.lastProbeAt < (this.deps.probeIntervalMs ?? 5e3)) return false;
515
+ this.lastProbeAt = now;
516
+ const local = this.readSession(this.room.rejoin.serverRoomName)?.serverUrl;
517
+ if (!local || !isLoopbackUrl(local) || sameOrigin(local, this.room.serverUrl)) return false;
518
+ const base = local.replace(/\/$/, "");
519
+ let res;
520
+ try {
521
+ res = await this.fetchFn(`${base}/participants`, {
522
+ headers: { Authorization: `Bearer ${this.room.sessionToken}` },
523
+ signal: AbortSignal.timeout(5e3)
524
+ });
525
+ } catch {
526
+ return false;
527
+ }
528
+ await res.body?.cancel().catch(() => {
529
+ });
530
+ if (res.ok) {
531
+ const from = this.room.serverUrl;
532
+ this.swapSession(base, this.room.sessionToken);
533
+ this.log(`room "${this.room.roomName}": ${from} unreachable, moved to ${base} (same session)`);
534
+ return true;
535
+ }
536
+ if (res.status === 401) {
537
+ try {
538
+ await this.rejoin(`${this.room.serverUrl} unreachable`);
539
+ return true;
540
+ } catch {
541
+ return false;
542
+ }
543
+ }
544
+ return false;
545
+ }
546
+ // ── SseRecoveryHandler (per room) ──────────────────────────────────────
547
+ async authRejected(status, usedToken) {
548
+ if (usedToken !== void 0 && usedToken !== this.room.sessionToken) return true;
549
+ try {
550
+ await this.rejoin(`event stream rejected (${status})`);
551
+ return true;
552
+ } catch (err) {
553
+ if (err instanceof RoomConnectionError && err.permanent) throw err;
554
+ return false;
555
+ }
556
+ }
557
+ // ── Re-join ────────────────────────────────────────────────────────────
558
+ /**
559
+ * Join the room again and swap the new session in everywhere. Concurrent
560
+ * callers share one attempt. Throws RoomConnectionError: `permanent` when
561
+ * retrying cannot help, otherwise after bounded retries.
562
+ */
563
+ rejoin(reason) {
564
+ if (this.lost) return Promise.reject(this.lost);
565
+ const roomLabel = `"${this.room.roomName}"`;
566
+ if (this.room.removedBy) return Promise.reject(this.removed());
567
+ if (this.inflight) return this.inflight;
568
+ const now = this.now();
569
+ if (now - this.lastRejoinAt < (this.deps.replacedWindowMs ?? 3e4)) {
570
+ return Promise.reject(this.loseFor(
571
+ `Your seat in room ${roomLabel} was taken over again right after apiary re-joined it \u2014 another session is probably using the name "${this.room.rejoin.name}". Not re-joining automatically, so the two do not keep evicting each other. Call join_room to take the seat back.`
572
+ ));
573
+ }
574
+ if (now < this.cooldownUntil) {
575
+ const secs = Math.ceil((this.cooldownUntil - now) / 1e3);
576
+ return Promise.reject(new RoomConnectionError(
577
+ `Lost the session in room ${roomLabel} and the last re-join failed; apiary retries in ${secs}s. If this persists, call join_room again with the room's share URL.`,
578
+ false
579
+ ));
580
+ }
581
+ this.inflight = this.runRejoin(reason).finally(() => {
582
+ this.inflight = null;
583
+ });
584
+ return this.inflight;
585
+ }
586
+ async runRejoin(reason) {
587
+ const roomLabel = `"${this.room.roomName}"`;
588
+ this.log(`room ${roomLabel}: ${reason}; re-joining as "${this.room.rejoin.name}"`);
589
+ const delays = [0, ...this.deps.retryDelaysMs ?? [500, 1500]];
590
+ let lastError = "";
591
+ for (const delay of delays) {
592
+ if (delay > 0) await this.sleep(delay);
593
+ if (this.room.removedBy) break;
594
+ const outcome = await this.attemptOnce();
595
+ if (outcome.permanent) {
596
+ throw this.loseFor(
597
+ `Room ${roomLabel} no longer accepts the share link this agent joined with (${outcome.error}). The room was recreated or the link revoked \u2014 ask for a new share URL and call join_room with it.`
598
+ );
599
+ }
600
+ if (outcome.done) {
601
+ this.failures = 0;
602
+ this.cooldownUntil = 0;
603
+ this.lastRejoinAt = this.now();
604
+ return;
605
+ }
606
+ lastError = outcome.error;
607
+ }
608
+ if (this.room.removedBy) throw this.removed();
609
+ this.failures += 1;
610
+ const cooldown = Math.min(1e3 * 2 ** (this.failures - 1), this.deps.maxCooldownMs ?? 3e4);
611
+ this.cooldownUntil = this.now() + cooldown;
612
+ this.log(`room ${roomLabel}: re-join failed (${lastError}); next try in ${Math.ceil(cooldown / 1e3)}s`);
613
+ throw new RoomConnectionError(
614
+ `Lost the session in room ${roomLabel} (${reason}) and could not re-join yet: ${lastError}. apiary keeps retrying; if this persists, call join_room again with the room's share URL.`,
615
+ false
616
+ );
617
+ }
618
+ /** One pass over the candidate addresses and tokens. */
619
+ async attemptOnce() {
620
+ const info = this.room.rejoin;
621
+ const candidates = [
622
+ { url: this.room.serverUrl, shareToken: info.shareToken }
623
+ ];
624
+ const session = this.readSession(info.serverRoomName);
625
+ const local = session?.serverUrl && isLoopbackUrl(session.serverUrl) ? session.serverUrl.replace(/\/$/, "") : void 0;
626
+ if (local && !sameOrigin(local, this.room.serverUrl)) {
627
+ candidates.push({ url: local, shareToken: info.shareToken });
628
+ }
629
+ const fresh = info.localTier === "admin" ? session?.adminToken : info.localTier === "member" ? session?.memberToken : void 0;
630
+ if (local && fresh && fresh !== info.shareToken) {
631
+ candidates.push({ url: local, shareToken: fresh });
632
+ }
633
+ let shareRejected = "";
634
+ let transient = "";
635
+ let unreachable = "";
636
+ for (const c of candidates) {
637
+ const r = await this.joinAt(c.url, c.shareToken);
638
+ if (r.ok) {
639
+ this.apply(r.url, r.shareToken, r.data);
640
+ return { done: true, permanent: false, error: "" };
641
+ }
642
+ if (r.kind === "share-rejected") shareRejected = r.error;
643
+ else if (r.kind === "unreachable") unreachable ||= r.error;
644
+ else transient ||= r.error;
645
+ }
646
+ if (shareRejected && !transient) return { done: false, permanent: true, error: shareRejected };
647
+ return { done: false, permanent: false, error: transient || unreachable || shareRejected || "no address answered" };
648
+ }
649
+ async joinAt(url, shareToken) {
650
+ const body = { type: this.room.rejoin.type, name: this.room.rejoin.name };
651
+ if (shareToken) body.token = shareToken;
652
+ let res;
653
+ try {
654
+ res = await this.fetchFn(`${url}/join`, {
655
+ method: "POST",
656
+ headers: { "Content-Type": "application/json" },
657
+ body: JSON.stringify(body),
658
+ signal: AbortSignal.timeout(1e4)
659
+ });
660
+ } catch (err) {
661
+ return { ok: false, kind: "unreachable", error: `${url} unreachable (${err instanceof Error ? err.message : String(err)})` };
662
+ }
663
+ if (!res.ok) {
664
+ const text = (await res.text().catch(() => "")).slice(0, 200);
665
+ if (res.status === 403 && /invalid share token/i.test(text)) {
666
+ return { ok: false, kind: "share-rejected", error: `403 Invalid share token at ${url}` };
667
+ }
668
+ return { ok: false, kind: "transient", error: `${res.status} from ${url}/join${text ? `: ${text}` : ""}` };
669
+ }
670
+ const data = await res.json().catch(() => ({}));
671
+ if (String(data.roomName ?? "") !== this.room.rejoin.serverRoomName || String(data.roomId ?? "") !== this.room.roomId) {
672
+ await this.fetchFn(`${url}/disconnect`, {
673
+ method: "POST",
674
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${String(data.sessionToken ?? "")}` },
675
+ body: "{}"
676
+ }).catch(() => {
677
+ });
678
+ return { ok: false, kind: "wrong-room", error: `${url} serves a different room (${String(data.roomName ?? "?")})` };
679
+ }
680
+ return { ok: true, url, shareToken, data };
681
+ }
682
+ /** Swap the new session into the JoinResult, data source, processor and SSE stream. */
683
+ apply(url, shareToken, data) {
684
+ const room = this.room;
685
+ const oldId = room.participantId;
686
+ const token = String(data.sessionToken ?? "");
687
+ const newId = String(data.participantId ?? "");
688
+ const participants = data.participants ?? [];
689
+ room.participantId = newId;
690
+ room.authority = String(data.authority ?? room.authority);
691
+ room.participants = participants;
692
+ if (shareToken !== void 0) room.rejoin.shareToken = shareToken;
693
+ room.dataSource.setParticipants(participants);
694
+ room.dataSource.setSelf(newId, room.rejoin.name);
695
+ const { processor } = this.deps;
696
+ processor.setRoomParticipantId(room.roomId, newId);
697
+ if (processor.participantId === oldId) processor.participantId = newId;
698
+ this.swapSession(url, token);
699
+ this.log(`room "${room.roomName}": re-joined as "${room.rejoin.name}" at ${url}`);
700
+ const mode = processor.getModeForRoom(room.roomId);
701
+ if (mode !== "everyone") {
702
+ this.fetchFn(`${url}/set-mode`, {
703
+ method: "POST",
704
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
705
+ body: JSON.stringify({ mode })
706
+ }).catch(() => {
707
+ });
708
+ }
709
+ }
710
+ swapSession(url, token) {
711
+ this.room.serverUrl = url;
712
+ this.room.sessionToken = token;
713
+ this.room.dataSource.setSession(url, token);
714
+ this.deps.sseMux.updateConnection(this.room.roomId, url, token);
715
+ }
716
+ removed() {
717
+ return this.loseFor(
718
+ `You were removed from room "${this.room.roomName}" by ${this.room.removedBy}, so apiary will not re-join it. Call join_room again only if you are invited back.`
719
+ );
720
+ }
721
+ loseFor(message) {
722
+ if (!this.lost) {
723
+ this.lost = new RoomConnectionError(message, true);
724
+ this.log(`room "${this.room.roomName}": giving up \u2014 ${message}`);
725
+ try {
726
+ this.deps.onLost?.(this.room, this.lost);
727
+ } catch {
728
+ }
729
+ }
730
+ return this.lost;
731
+ }
732
+ log(line) {
733
+ this.deps.log?.(line);
734
+ }
735
+ };
736
+
440
737
  // src/cli/runtime-handlers.ts
738
+ function trackRoomEvent(jr, event) {
739
+ if (event.type === "ParticipantJoined") {
740
+ jr.dataSource.addParticipant(event.participant);
741
+ } else if (event.type === "ParticipantLeft") {
742
+ jr.dataSource.removeParticipant(event.participant_id);
743
+ } else if (event.type === "ParticipantKicked") {
744
+ if (event.participant_id === jr.participantId) jr.removedBy = safeName(event.kicked_by);
745
+ jr.dataSource.removeParticipant(event.participant_id);
746
+ }
747
+ }
748
+ function safeName(name) {
749
+ return String(name ?? "").replace(/[^A-Za-z0-9 _.-]/g, "").trim().slice(0, 40) || "an admin";
750
+ }
441
751
  function buildRuntimeMcpOptions(ctx) {
442
752
  const { processor, sseMux, joinResults, agentName, authority, onRoomJoined } = ctx;
443
- const adminAction = (build) => async (room, participant) => {
444
- const conn = processor.resolve(room);
445
- if (!conn) return { success: false, error: `Unknown room "${room}".` };
446
- const ds = conn.dataSource;
447
- const p = conn.dataSource.listParticipants().find((pp) => pp.name === participant);
448
- if (!p) return { success: false, error: `Unknown participant "${participant}".` };
449
- const { path, body } = build(p.id);
753
+ const rejoinerFor = (roomId) => joinResults.find((j) => j.roomId === roomId)?.rejoiner;
754
+ sseMux.setRecoveryHandler({
755
+ authRejected: async (roomId, status, usedToken) => {
756
+ const r = rejoinerFor(roomId);
757
+ return r ? r.authRejected(status, usedToken) : false;
758
+ },
759
+ unreachable: async (roomId) => await rejoinerFor(roomId)?.unreachable() ?? false
760
+ });
761
+ const post = async (ds, path, body) => {
450
762
  try {
451
- const res = await fetch(`${ds.serverUrl}${path}`, {
763
+ const res = await ds.request(path, {
452
764
  method: "POST",
453
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${ds.sessionToken}` },
765
+ headers: { "Content-Type": "application/json" },
454
766
  body: JSON.stringify(body)
455
767
  });
456
768
  if (!res.ok) return { success: false, error: await res.text() };
457
769
  return { success: true };
458
- } catch {
459
- return { success: false, error: "Server unreachable." };
770
+ } catch (err) {
771
+ const msg = err instanceof Error && err.name === "RoomConnectionError" ? err.message : "Server unreachable.";
772
+ return { success: false, error: msg };
460
773
  }
461
774
  };
775
+ const adminAction = (build) => async (room, participant) => {
776
+ const conn = processor.resolve(room);
777
+ if (!conn) return { success: false, error: `Unknown room "${room}".` };
778
+ const ds = conn.dataSource;
779
+ const p = conn.dataSource.listParticipants().find((pp) => pp.name === participant);
780
+ if (!p) return { success: false, error: `Unknown participant "${participant}".` };
781
+ const { path, body } = build(p.id);
782
+ return post(ds, path, body);
783
+ };
462
784
  return {
463
785
  resolver: processor,
464
786
  toolOptions: {
@@ -472,15 +794,9 @@ function buildRuntimeMcpOptions(ctx) {
472
794
  const conn = processor.resolve(room);
473
795
  if (!conn) return { success: false, error: `Unknown room "${room}".` };
474
796
  processor.setModeForRoom(conn.dataSource.roomId, mode, false);
475
- try {
476
- const ds = conn.dataSource;
477
- const res = await fetch(`${ds.serverUrl}/set-mode`, {
478
- method: "POST",
479
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${ds.sessionToken}` },
480
- body: JSON.stringify({ mode })
481
- });
482
- if (!res.ok) return { success: false, error: `Server rejected: ${await res.text()}` };
483
- } catch {
797
+ const result = await post(conn.dataSource, "/set-mode", { mode });
798
+ if (!result.success && result.error !== "Server unreachable.") {
799
+ return { success: false, error: `Server rejected: ${result.error}` };
484
800
  }
485
801
  return { success: true };
486
802
  },
@@ -500,10 +816,7 @@ function buildRuntimeMcpOptions(ctx) {
500
816
  }
501
817
  const readState = async () => {
502
818
  try {
503
- const res = await fetch(`${ds.serverUrl}/participants`, {
504
- headers: { Authorization: `Bearer ${ds.sessionToken}` },
505
- signal: AbortSignal.timeout(1e4)
506
- });
819
+ const res = await ds.request("/participants", { signal: AbortSignal.timeout(1e4) });
507
820
  if (!res.ok) return "unknown";
508
821
  const data = await res.json();
509
822
  const found = data.participants?.find((pp) => pp.id === target.id);
@@ -526,17 +839,7 @@ function buildRuntimeMcpOptions(ctx) {
526
839
  const ds = conn.dataSource;
527
840
  const p = conn.dataSource.listParticipants().find((pp) => pp.name === participant);
528
841
  if (!p) return { success: false, error: `Unknown participant "${participant}".` };
529
- try {
530
- const res = await fetch(`${ds.serverUrl}/ping`, {
531
- method: "POST",
532
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${ds.sessionToken}` },
533
- body: JSON.stringify({ participantId: p.id })
534
- });
535
- if (!res.ok) return { success: false, error: await res.text() };
536
- return { success: true };
537
- } catch {
538
- return { success: false, error: "Server unreachable." };
539
- }
842
+ return post(ds, "/ping", { participantId: p.id });
540
843
  },
541
844
  onJoinRoom: async (url, alias, nameOverride) => {
542
845
  const token = extractToken(url);
@@ -566,6 +869,12 @@ function buildRuntimeMcpOptions(ctx) {
566
869
  const roomAuthority = String(data.authority ?? "member");
567
870
  const participants = data.participants ?? [];
568
871
  const newParticipantId = String(data.participantId ?? "");
872
+ const existingIdx = joinResults.findIndex((j) => j.roomId === roomId);
873
+ if (existingIdx >= 0) {
874
+ sseMux.removeConnection(roomId);
875
+ processor.disconnectRemoteRoom(roomId);
876
+ joinResults.splice(existingIdx, 1);
877
+ }
569
878
  const dataSource = new RemoteRoomDataSource(serverUrl, sessionToken, roomId);
570
879
  dataSource.setParticipants(participants);
571
880
  dataSource.setSelf(newParticipantId, joinName);
@@ -576,6 +885,7 @@ function buildRuntimeMcpOptions(ctx) {
576
885
  const mode = processor.getModeForRoom(roomId) ?? "everyone";
577
886
  processor.connectRemoteRoom(dataSource, roomName);
578
887
  sseMux.addConnection(serverUrl, sessionToken, roomName, roomId);
888
+ const serverRoomName = String(data.roomName ?? "");
579
889
  const jr = {
580
890
  serverUrl,
581
891
  sessionToken,
@@ -584,8 +894,27 @@ function buildRuntimeMcpOptions(ctx) {
584
894
  roomId,
585
895
  authority: roomAuthority,
586
896
  participants,
587
- dataSource
897
+ dataSource,
898
+ rejoin: {
899
+ joinUrl: url,
900
+ shareToken: token ?? void 0,
901
+ name: joinName,
902
+ type: "agent",
903
+ serverRoomName,
904
+ localTier: localTierOf(
905
+ token ?? void 0,
906
+ (ctx.rejoinOverrides?.readRoomSession ?? readLocalRoomSession)(serverRoomName)
907
+ )
908
+ }
588
909
  };
910
+ jr.rejoiner = new RoomRejoiner(jr, {
911
+ ...ctx.rejoinOverrides,
912
+ sseMux,
913
+ processor,
914
+ log: ctx.log,
915
+ onLost: ctx.onRoomLost ? (_room, err) => ctx.onRoomLost(jr, err) : void 0
916
+ });
917
+ dataSource.setRecovery(jr.rejoiner);
589
918
  joinResults.push(jr);
590
919
  const conn = processor.resolve(roomName);
591
920
  let recentLines = [];
@@ -631,6 +960,7 @@ function buildRuntimeMcpOptions(ctx) {
631
960
  const idx = joinResults.findIndex((jr) => jr.roomId === roomId);
632
961
  if (idx >= 0) {
633
962
  const jr = joinResults[idx];
963
+ jr.dataSource.setRecovery(null);
634
964
  sseMux.removeConnection(roomId);
635
965
  processor.disconnectRemoteRoom(roomId);
636
966
  try {
@@ -658,6 +988,7 @@ function buildRuntimeMcpOptions(ctx) {
658
988
  export {
659
989
  composerStillHolds,
660
990
  TmuxBridge,
991
+ trackRoomEvent,
661
992
  buildRuntimeMcpOptions
662
993
  };
663
- //# sourceMappingURL=chunk-36WX2UAF.js.map
994
+ //# sourceMappingURL=chunk-OEAHTZCA.js.map