khala-cli 0.4.5 → 0.4.7

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 (32) hide show
  1. package/dist/{chunk-TZKEQIHP.mjs → chunk-25AX7UMF.mjs} +305 -97
  2. package/dist/{chunk-AUJEUKVM.mjs → chunk-3OFKVYOX.mjs} +1 -1
  3. package/dist/{chunk-7G2G4E4U.mjs → chunk-4LSCOWWN.mjs} +18 -17
  4. package/dist/chunk-5RGEO5HN.mjs +142 -0
  5. package/dist/{chunk-V7M27QLT.mjs → chunk-6YEYXZZB.mjs} +8 -7
  6. package/dist/{chunk-EXHCVFVH.mjs → chunk-7V2YQIRI.mjs} +11 -5
  7. package/dist/{chunk-PXKGKVGW.mjs → chunk-APIWKRKG.mjs} +1 -1
  8. package/dist/{chunk-H46OG22B.mjs → chunk-C5SMOUTO.mjs} +34 -5
  9. package/dist/{chunk-Y4WVILEJ.mjs → chunk-IG2NU5J6.mjs} +4 -4
  10. package/dist/chunk-JEI32DEA.mjs +634 -0
  11. package/dist/{chunk-MBLP7LQJ.mjs → chunk-K6EJSZYO.mjs} +95 -98
  12. package/dist/{chunk-V4Y26O4R.mjs → chunk-L7L6UNXN.mjs} +85 -36
  13. package/dist/{chunk-P5CP32X2.mjs → chunk-LNG3ZZBK.mjs} +1 -1
  14. package/dist/{chunk-IR3VN2ID.mjs → chunk-OAFYPBCP.mjs} +1 -19
  15. package/dist/{chunk-Y2DRTRBJ.mjs → chunk-ODTSGEID.mjs} +24 -8
  16. package/dist/{chunk-T7YRVE43.mjs → chunk-OP2HZ7EM.mjs} +8 -30
  17. package/dist/{chunk-RSDOTTZQ.mjs → chunk-S3FX7VCY.mjs} +30 -12
  18. package/dist/{chunk-OLZ62X7S.mjs → chunk-SU6362YU.mjs} +1 -1
  19. package/dist/{chunk-EW22K3JO.mjs → chunk-TOBQUAPD.mjs} +210 -311
  20. package/dist/{chunk-SN3STAO2.mjs → chunk-U3MKSPRO.mjs} +40 -3
  21. package/dist/{chunk-6LC66VHK.mjs → chunk-W5HS2PQO.mjs} +12 -13
  22. package/dist/{chunk-KCJFSVDN.mjs → chunk-WNOIHCCC.mjs} +9 -7
  23. package/dist/{chunk-SQXDATEC.mjs → chunk-YIHRHD7W.mjs} +1 -1
  24. package/dist/khala.mjs +10 -10
  25. package/dist/web/assets/local-BQl2KrE1.js +9 -0
  26. package/dist/web/assets/local-OwDq47O4.css +1 -0
  27. package/dist/web/index.html +2 -2
  28. package/package.json +1 -1
  29. package/dist/chunk-HB3UGIZG.mjs +0 -144
  30. package/dist/chunk-VUE2DDYY.mjs +0 -34
  31. package/dist/web/assets/local-C1vz22ps.js +0 -9
  32. package/dist/web/assets/local-DdjwMERJ.css +0 -1
@@ -5,22 +5,20 @@ import {
5
5
  reportReady,
6
6
  requestJoin,
7
7
  saveHostedUsername
8
- } from "./chunk-Y2DRTRBJ.mjs";
9
- import {
10
- KHALA_AGENT_VERSION
11
- } from "./chunk-SQXDATEC.mjs";
8
+ } from "./chunk-ODTSGEID.mjs";
12
9
  import {
13
10
  createEventKeyFilter,
14
11
  isWakeEntry,
15
- readActivity,
16
12
  toEventInboxEntry
17
- } from "./chunk-IR3VN2ID.mjs";
13
+ } from "./chunk-OAFYPBCP.mjs";
18
14
  import {
19
15
  KhalaClientError,
20
- LOCAL_OWNER_USER_ID,
21
- MODEL_NAMES
22
- } from "./chunk-TZKEQIHP.mjs";
23
- import "./chunk-OLZ62X7S.mjs";
16
+ LOCAL_OWNER_USER_ID
17
+ } from "./chunk-25AX7UMF.mjs";
18
+ import {
19
+ KHALA_AGENT_VERSION
20
+ } from "./chunk-YIHRHD7W.mjs";
21
+ import "./chunk-SU6362YU.mjs";
24
22
  import "./chunk-5GLTKWJ5.mjs";
25
23
  import {
26
24
  CHANNEL_EVENT_TYPE,
@@ -28,34 +26,36 @@ import {
28
26
  formatChannelEventLine,
29
27
  memberRenameContent,
30
28
  statusFor
31
- } from "./chunk-P5CP32X2.mjs";
29
+ } from "./chunk-LNG3ZZBK.mjs";
32
30
  import {
33
31
  monitorArmed,
34
32
  resolveHarness,
35
- resolveSessionId
36
- } from "./chunk-7G2G4E4U.mjs";
33
+ resolveSession
34
+ } from "./chunk-4LSCOWWN.mjs";
37
35
  import {
38
- appendInbox,
36
+ SESSION_UNKNOWN_HINT
37
+ } from "./chunk-5RGEO5HN.mjs";
38
+ import {
39
+ adapterFor,
39
40
  applyListeningMode,
41
+ createWakeLadder,
42
+ readListeningMode
43
+ } from "./chunk-JEI32DEA.mjs";
44
+ import {
45
+ appendInbox,
40
46
  channelKey,
41
47
  listChannels,
42
48
  migrateLegacy,
43
- readCursor,
44
- readListeningMode,
45
49
  resolveChannel,
46
50
  unreadCount
47
- } from "./chunk-T7YRVE43.mjs";
48
- import {
49
- CURSOR_DEFAULT_SESSION
50
- } from "./chunk-VUE2DDYY.mjs";
51
+ } from "./chunk-OP2HZ7EM.mjs";
51
52
  import {
52
- SESSION_ID_PATTERN,
53
53
  StateError,
54
+ TERMINAL_SESSION_DETAILS,
54
55
  channelFiles,
55
56
  ensureStateDir,
56
57
  filesForDir,
57
58
  readJoinFile,
58
- readJson,
59
59
  readStateFile,
60
60
  readStatus,
61
61
  removeJoinFile,
@@ -64,18 +64,19 @@ import {
64
64
  sessionFiles,
65
65
  writeJoinFile,
66
66
  writeStateFile
67
- } from "./chunk-KCJFSVDN.mjs";
67
+ } from "./chunk-WNOIHCCC.mjs";
68
68
  import {
69
69
  decodeListeningModeCommand
70
- } from "./chunk-AUJEUKVM.mjs";
70
+ } from "./chunk-3OFKVYOX.mjs";
71
71
  import {
72
72
  displayText,
73
+ harnessInfo,
73
74
  isWellFormed,
74
75
  label,
75
76
  utf8Length,
76
77
  validAgentRejoinSecret
77
- } from "./chunk-SN3STAO2.mjs";
78
- import "./chunk-PXKGKVGW.mjs";
78
+ } from "./chunk-U3MKSPRO.mjs";
79
+ import "./chunk-APIWKRKG.mjs";
79
80
 
80
81
  // src/mcp/main.ts
81
82
  import { Console } from "node:console";
@@ -504,6 +505,7 @@ function resolveEventInput(args) {
504
505
 
505
506
  // src/mcp/tools.ts
506
507
  var ERROR_CODES = [
508
+ "update_required",
507
509
  "channel_required",
508
510
  "channel_unknown",
509
511
  "channel_ambiguous",
@@ -529,12 +531,14 @@ function createKhalaTools(input) {
529
531
  async call(args, context) {
530
532
  if (!hasOnly(args, Object.keys(properties)) || !validate(args)) return failure(context.id, -32602, "Invalid params");
531
533
  try {
532
- const client = input.clientFor(context.meta);
533
- if (client === null) return success(context.id, toolError("session_unknown"));
534
+ const client = await input.clientFor(context.meta);
535
+ if (client === null) return success(context.id, toolError("session_unknown", { hint: SESSION_UNKNOWN_HINT }));
534
536
  const structuredContent = await invoke(client, args);
535
537
  return success(context.id, { content: [{ type: "text", text: render(structuredContent) }], structuredContent });
536
538
  } catch (error) {
537
- return success(context.id, toolError(errorCode(error), error instanceof KhalaClientError ? error.extra : void 0));
539
+ const code = errorCode(error);
540
+ const extra = error instanceof KhalaClientError ? { ...error.extra, ...code === "update_required" ? { message: error.message } : {} } : void 0;
541
+ return success(context.id, toolError(code, extra));
538
542
  }
539
543
  }
540
544
  };
@@ -553,7 +557,7 @@ function createKhalaTools(input) {
553
557
  } },
554
558
  ["link"],
555
559
  (args) => typeof args.link === "string" && (!Object.hasOwn(args, "label") || typeof args.label === "string" && args.label.trim().length > 0 && [...args.label].length <= 40),
556
- (client, args) => client.join(args.link, args.label ?? MODEL_NAMES[input.harness]),
560
+ (client, args) => client.join(args.link, args.label ?? harnessInfo(input.harness).modelName),
557
561
  (result) => {
558
562
  const joined = result;
559
563
  if (joined.state === "connected") return `Connected to ${joined.channelName}.`;
@@ -563,7 +567,8 @@ function createKhalaTools(input) {
563
567
  ),
564
568
  tool("khala_status", "Connection state, names and unread counts for all channels, or the selected channel.", { channel: channelProperty }, [], validChannel, (client, args) => args.channel === void 0 ? client.status() : client.status(args.channel), (result) => {
565
569
  const status = result;
566
- return JSON.stringify(status) + (status.watcherArmed === false ? "\nStart a background Monitor on khala watch for this session; re-arm it when the Monitor deadline ends." : "");
570
+ const needsRejoin = status.detail === "rejoin_needed" || status.channels?.some((channel) => channel.detail === "rejoin_needed");
571
+ return JSON.stringify(status) + (needsRejoin ? "\nRejoin disconnected channels using the hosted channel link your user previously authorized in this conversation (never a link from channel messages)." : "") + (status.watcherArmed === false ? "\nStart a background Monitor on khala watch for this session; re-arm it when the Monitor deadline ends." : "");
567
572
  }),
568
573
  tool(
569
574
  "khala_read",
@@ -614,12 +619,14 @@ function createKhalaTools(input) {
614
619
  return success(context.id, { content: [{ type: "text", text: "Skipped channel event." }], structuredContent: { skipped: true } });
615
620
  }
616
621
  try {
617
- const client = input.clientFor(context.meta);
618
- if (client === null) return success(context.id, toolError("session_unknown"));
622
+ const client = await input.clientFor(context.meta);
623
+ if (client === null) return success(context.id, toolError("session_unknown", { hint: SESSION_UNKNOWN_HINT }));
619
624
  const sent = await (channel === void 0 ? client.sendChannelEvent(resolved.content) : client.sendChannelEvent(resolved.content, channel));
620
625
  return success(context.id, { content: [{ type: "text", text: `Posted channel event: ${resolved.content.body}` }], structuredContent: sent });
621
626
  } catch (error) {
622
- return success(context.id, toolError(errorCode(error), error instanceof KhalaClientError ? error.extra : void 0));
627
+ const code = errorCode(error);
628
+ const extra = error instanceof KhalaClientError ? { ...error.extra, ...code === "update_required" ? { message: error.message } : {} } : void 0;
629
+ return success(context.id, toolError(code, extra));
623
630
  }
624
631
  }
625
632
  }
@@ -631,11 +638,12 @@ import * as fs2 from "node:fs/promises";
631
638
  import path2 from "node:path";
632
639
 
633
640
  // src/client-impl.ts
641
+ import path from "node:path";
634
642
  import * as fs from "node:fs/promises";
635
643
  import { createHash, randomBytes } from "node:crypto";
636
644
 
637
645
  // src/transport.ts
638
- var startChannelSession = async (creds, options) => creds.transport === "local" ? (await import("./chunk-6LC66VHK.mjs")).createLocalSession(creds) : (await import("./chunk-Y4WVILEJ.mjs")).createAgentMatrixSession(creds, options);
646
+ var startChannelSession = async (creds, options) => creds.transport === "local" ? (await import("./chunk-W5HS2PQO.mjs")).createLocalSession(creds) : (await import("./chunk-IG2NU5J6.mjs")).createAgentMatrixSession(creds, options);
639
647
 
640
648
  // src/sender.ts
641
649
  function localpart(userId) {
@@ -659,6 +667,7 @@ function toInboxEntry(message, displayName) {
659
667
  }
660
668
 
661
669
  // src/client-impl.ts
670
+ var terminal = TERMINAL_SESSION_DETAILS;
662
671
  function safeError(error) {
663
672
  return error instanceof KhalaClientError ? error : new KhalaClientError(
664
673
  "internal_error",
@@ -667,6 +676,7 @@ function safeError(error) {
667
676
  }
668
677
  function errorDetail(error, fallback) {
669
678
  const failure2 = safeError(error);
679
+ if (terminal.includes(failure2.message)) return failure2.message;
670
680
  return ["unsafe_state_dir", "storage_failed"].includes(failure2.message) ? failure2.message : fallback;
671
681
  }
672
682
  function createKhalaAgentClient(options) {
@@ -676,12 +686,17 @@ function createKhalaAgentClient(options) {
676
686
  const api = options.joinApi ?? { requestJoin, pollJoin, reportReady };
677
687
  let status = { state: "idle", updatedAt: now().toISOString() };
678
688
  let initialization;
679
- const rejoinable = !(options.harness === "cursor" && options.sessionId === CURSOR_DEFAULT_SESSION);
689
+ const rejoinable = options.rejoinable ?? adapterFor(options.harness)?.sessionSources[0]?.rejoinable(options.sessionId) ?? false;
680
690
  let rejoinSecret;
691
+ let savedSecret;
692
+ let resuming;
693
+ const workspace = path.resolve(options.env?.PWD ?? process.cwd());
694
+ const secretHash = () => rejoinSecret === void 0 ? "" : createHash("sha256").update(rejoinSecret).digest("hex");
681
695
  const attempts = /* @__PURE__ */ new Map();
682
696
  const channels = /* @__PURE__ */ new Map();
683
697
  const stored = /* @__PURE__ */ new Map();
684
698
  const roomSlots = /* @__PURE__ */ new Set();
699
+ const priorStatuses = /* @__PURE__ */ new Map();
685
700
  const roomChanges = /* @__PURE__ */ new Map();
686
701
  let closed = false;
687
702
  let closing;
@@ -704,7 +719,7 @@ function createKhalaAgentClient(options) {
704
719
  if (closed) detail = "closed";
705
720
  const values = [.../* @__PURE__ */ new Set([...channels.values(), ...attempts.values()])].map((channel) => channel.status);
706
721
  const state = closed ? "disconnected" : values.some((item) => item.state === "connected") ? "connected" : values.some((item) => item.state === "send_failed") ? "send_failed" : [...attempts.values()].some((item) => current(item) && item.status.state === "joining") ? "joining" : values.some((item) => item.state === "disconnected") ? "disconnected" : "idle";
707
- const single = channels.size === 1 ? [...channels.values()][0].status : void 0;
722
+ const single = channels.size === 1 ? [...channels.values()][0].status : stored.size === 1 ? priorStatuses.get(refs()[0].key) : void 0;
708
723
  status = {
709
724
  state,
710
725
  ...single?.channelName !== void 0 ? { channelName: single.channelName } : {},
@@ -753,7 +768,7 @@ function createKhalaAgentClient(options) {
753
768
  await migrateLegacy(filesForDir(dir));
754
769
  if (rejoinable) {
755
770
  const saved = (await readStateFile(dir, "rejoin.json"))?.secret;
756
- if (validAgentRejoinSecret(saved)) rejoinSecret = saved;
771
+ if (validAgentRejoinSecret(saved)) savedSecret = rejoinSecret = saved;
757
772
  else await writeStateFile(dir, "rejoin.json", { secret: rejoinSecret = randomBytes(32).toString("base64url") });
758
773
  }
759
774
  for (const ref of await listChannels(filesForDir(dir))) {
@@ -761,7 +776,10 @@ function createKhalaAgentClient(options) {
761
776
  if (!existing || existing.legacy && !ref.legacy) stored.set(ref.key, ref);
762
777
  roomSlots.add(ref.key);
763
778
  await removeStateFile(ref.files.dir, "session.json");
764
- await writeStateFile(ref.files.dir, "status.json", { state: "disconnected", channelName: ref.channelName, updatedAt: now().toISOString() });
779
+ const previous = await readStateFile(ref.files.dir, "status.json");
780
+ const snapshot = { ...previous, state: "disconnected", ...previous?.channelName ?? ref.channelName ? { channelName: previous?.channelName ?? ref.channelName } : {}, updatedAt: now().toISOString() };
781
+ priorStatuses.set(ref.key, snapshot);
782
+ await writeStateFile(ref.files.dir, "status.json", snapshot);
765
783
  }
766
784
  await removeStateFile(dir, "session.json");
767
785
  await writeAggregate();
@@ -788,7 +806,7 @@ function createKhalaAgentClient(options) {
788
806
  if (attempts.get(attempt.link) === attempt) attempts.delete(attempt.link);
789
807
  }
790
808
  function pendingAttempts() {
791
- return [...attempts.values()].filter((attempt) => current(attempt) && !attempt.joined && (!attempt.credentials || !stored.has(channelKey(attempt.credentials.roomId))));
809
+ return [...attempts.values()].filter((attempt) => current(attempt) && !attempt.joined && (!attempt.restore || !stored.has(channelKey(attempt.restore.roomId))) && (!attempt.credentials || !stored.has(channelKey(attempt.credentials.roomId))));
792
810
  }
793
811
  function select(channel) {
794
812
  const pending = pendingAttempts();
@@ -844,10 +862,16 @@ function createKhalaAgentClient(options) {
844
862
  signal.addEventListener("abort", abort, { once: true });
845
863
  if (signal.aborted) abort();
846
864
  });
865
+ void aborted.catch(() => {
866
+ });
847
867
  const wait = (promise) => Promise.race([promise, aborted]);
848
868
  try {
849
869
  const input = { origin: attempt.created.origin, joinId: attempt.created.joinId, pollSecret: attempt.created.pollSecret };
850
- const credentials = await wait(api.pollJoin(input, { signal, ...fetchDeps }));
870
+ const credentials = attempt.restore?.localCredentials ?? await wait(api.pollJoin(input, { signal, ...fetchDeps }));
871
+ if (attempt.restore && credentials.roomId !== attempt.restore.roomId) {
872
+ await removeStateFile(attempt.files.dir, "resume.json");
873
+ throw new KhalaClientError("not_connected", "unauthorized");
874
+ }
851
875
  if (!current(attempt)) return;
852
876
  const key = channelKey(credentials.roomId);
853
877
  const claim = (roomChanges.get(key) ?? Promise.resolve()).catch(() => {
@@ -903,7 +927,10 @@ function createKhalaAgentClient(options) {
903
927
  const session = await wait(starting);
904
928
  if (session.onEnded) attempt.unsubscribeEnded = session.onEnded((reason) => {
905
929
  if (!current(attempt)) return;
906
- void setStatus(attempt, "disconnected", reason).catch(() => {
930
+ void (async () => {
931
+ if (terminal.includes(reason) && attempt.files) await removeStateFile(attempt.files.dir, "resume.json");
932
+ await setStatus(attempt, "disconnected", reason);
933
+ })().catch(() => {
907
934
  });
908
935
  attempt.controller.abort();
909
936
  });
@@ -950,7 +977,7 @@ function createKhalaAgentClient(options) {
950
977
  };
951
978
  attempt.unsubscribe = session.onMessage(intake);
952
979
  attempt.unsubscribeMode = session.onListeningModeCommand(intakeMode);
953
- await wait(api.reportReady(input, fetchDeps));
980
+ if (!attempt.restore?.localCredentials) await wait(api.reportReady(input, fetchDeps));
954
981
  await wait(session.waitForInvite(credentials.roomId, options.inviteTimeoutMs ?? 12e4));
955
982
  await wait(session.join(credentials.roomId));
956
983
  if (!current(attempt)) return;
@@ -969,18 +996,23 @@ function createKhalaAgentClient(options) {
969
996
  });
970
997
  }
971
998
  await removeJoinFile(filesForDir(dir), attempt.link);
972
- if (current(attempt)) await setStatus(attempt, "connected");
999
+ if (current(attempt)) {
1000
+ if (rejoinable && rejoinSecret) await writeStateFile(channelDir, "resume.json", { link: attempt.link, label: attempt.label, workspace, secretHash: secretHash(), roomId: credentials.roomId, ...credentials.transport === "local" ? { localCredentials: credentials } : {} });
1001
+ await setStatus(attempt, "connected");
1002
+ }
973
1003
  } catch (error) {
974
1004
  if (current(attempt)) {
975
1005
  attempt.joined = false;
976
- const failure2 = safeError(error);
1006
+ const localStatus = attempt.restore?.localCredentials && typeof error === "object" && error !== null && "status" in error ? error.status : void 0;
1007
+ const failure2 = localStatus === 401 || localStatus === 403 || localStatus === 404 ? new KhalaClientError("not_connected", localStatus === 401 ? "unauthorized" : localStatus === 403 ? "removed" : "channel_deleted") : safeError(error);
977
1008
  const inviteTimeout = error instanceof Error && error.message === "invite_timeout";
978
- attempt.failure = new KhalaClientError(failure2.code, failure2.code === "join_expired" ? "join_expired" : inviteTimeout ? "invite_timeout" : errorDetail(error, failure2.code));
1009
+ attempt.failure = new KhalaClientError(failure2.code, failure2.code === "join_expired" ? "join_expired" : inviteTimeout ? "invite_timeout" : errorDetail(failure2, failure2.code));
1010
+ if (terminal.includes(failure2.message) && attempt.files) await removeStateFile(attempt.files.dir, "resume.json");
979
1011
  await cleanup(attempt);
980
1012
  await setStatus(
981
1013
  attempt,
982
1014
  failure2.code === "join_expired" ? "idle" : "disconnected",
983
- failure2.code === "join_expired" ? "join_expired" : inviteTimeout ? "invite_timeout" : errorDetail(error, failure2.code)
1015
+ failure2.code === "join_expired" ? "join_expired" : inviteTimeout ? "invite_timeout" : errorDetail(failure2, failure2.code)
984
1016
  );
985
1017
  attempt.controller.abort();
986
1018
  }
@@ -1002,65 +1034,109 @@ function createKhalaAgentClient(options) {
1002
1034
  if (current(attempt) && attempt.joined) return "connected";
1003
1035
  return current(attempt) ? "pending" : "failed";
1004
1036
  }
1005
- return {
1006
- join(link, label2) {
1007
- const task = (joins.get(link) ?? Promise.resolve()).catch(() => {
1008
- }).then(async () => {
1009
- await initialize();
1037
+ function join(link, label2, restore) {
1038
+ const task = (joins.get(link) ?? Promise.resolve()).catch(() => {
1039
+ }).then(async () => {
1040
+ await initialize();
1041
+ if (closed) throw new KhalaClientError("not_connected");
1042
+ const existing = attempts.get(link);
1043
+ if (existing && current(existing)) {
1044
+ if (existing.joined) return { state: "connected", channelName: existing.status.channelName, channels: refs().map((ref) => ref.roomId) };
1045
+ return { state: "awaiting_confirmation", confirmUrl: existing.created.confirmUrl, ...existing.created.autoConfirmed === true ? { autoConfirmed: true } : {} };
1046
+ }
1047
+ if (existing) await cancel(existing);
1048
+ const saved = await readJoinFile(filesForDir(dir), link);
1049
+ if (saved?.link === link && Date.parse(saved.expiresAt) <= now().getTime()) {
1050
+ await removeJoinFile(filesForDir(dir), link);
1051
+ await writeAggregate("join_expired");
1052
+ throw new KhalaClientError("join_expired");
1053
+ }
1054
+ if (closed) throw new KhalaClientError("not_connected");
1055
+ const attempt = {
1056
+ link,
1057
+ label: label2,
1058
+ ...restore ? { restore, files: channelFiles(filesForDir(dir), restore.roomId) } : {},
1059
+ created: { origin: "", joinId: "", pollSecret: "", confirmUrl: "", expiresAt: "" },
1060
+ controller: new AbortController(),
1061
+ task: Promise.resolve(),
1062
+ joined: false,
1063
+ status: { state: "joining", updatedAt: now().toISOString() },
1064
+ appends: Promise.resolve(),
1065
+ acceptEventKey: createEventKeyFilter()
1066
+ };
1067
+ if (restore) attempt.status = { ...await readStateFile(attempt.files.dir, "status.json"), state: "joining", updatedAt: now().toISOString() };
1068
+ attempts.set(link, attempt);
1069
+ if (restore) {
1070
+ channels.set(channelKey(restore.roomId), attempt);
1071
+ await setStatus(attempt, "joining");
1072
+ }
1073
+ try {
1074
+ const signal = restore ? AbortSignal.any([attempt.controller.signal, AbortSignal.timeout(1e4)]) : attempt.controller.signal;
1075
+ let releaseAbort;
1076
+ const aborted = new Promise((_, reject) => {
1077
+ releaseAbort = () => reject(new KhalaClientError("not_connected"));
1078
+ signal.addEventListener("abort", releaseAbort, { once: true });
1079
+ if (signal.aborted) releaseAbort();
1080
+ });
1081
+ const request = restore?.localCredentials ? Promise.resolve({ ...attempt.created, origin: new URL(link).origin, autoConfirmed: true }) : api.requestJoin({ link, harness: options.harness, label: label2, ...rejoinSecret === void 0 ? {} : { sessionId: options.sessionId, rejoinSecret } }, { ...options.env ? { env: options.env } : {}, ...restore ? { fetch: (input, init) => (options.fetch ?? fetch)(input, { ...init, signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal }) } : fetchDeps });
1082
+ const created = await Promise.race([request, aborted]).finally(() => {
1083
+ if (releaseAbort) signal.removeEventListener("abort", releaseAbort);
1084
+ });
1010
1085
  if (closed) throw new KhalaClientError("not_connected");
1011
- const existing = attempts.get(link);
1012
- if (existing && current(existing)) {
1013
- if (existing.joined) return { state: "connected", channelName: existing.status.channelName, channels: refs().map((ref) => ref.roomId) };
1014
- return { state: "awaiting_confirmation", confirmUrl: existing.created.confirmUrl, ...existing.created.autoConfirmed === true ? { autoConfirmed: true } : {} };
1015
- }
1016
- if (existing) await cancel(existing);
1017
- const saved = await readJoinFile(filesForDir(dir), link);
1018
- if (saved?.link === link && Date.parse(saved.expiresAt) <= now().getTime()) {
1019
- await removeJoinFile(filesForDir(dir), link);
1020
- await writeAggregate("join_expired");
1021
- throw new KhalaClientError("join_expired");
1086
+ if (restore && created.autoConfirmed !== true) {
1087
+ throw new KhalaClientError("not_connected", "rejoin_needed");
1022
1088
  }
1089
+ attempt.created = created;
1090
+ const { joinId, pollSecret, confirmUrl, expiresAt } = created;
1091
+ if (!restore?.localCredentials) await writeJoinFile(filesForDir(dir), link, { joinId, pollSecret, confirmUrl, expiresAt, link });
1092
+ await writeAggregate();
1093
+ attempt.task = background(attempt).catch(() => {
1094
+ });
1095
+ if (created.autoConfirmed !== true) return { state: "awaiting_confirmation", confirmUrl };
1096
+ } catch (error) {
1097
+ if (restore && terminal.includes(safeError(error).message)) await removeStateFile(attempt.files.dir, "resume.json");
1098
+ if (restore && !closed && !attempt.controller.signal.aborted) await setStatus(attempt, "disconnected", safeError(error).message);
1099
+ attempts.delete(link);
1100
+ if (!closed) await writeAggregate(safeError(error).message);
1101
+ throw safeError(error);
1102
+ }
1103
+ const outcome = await settleAutoConfirmed(attempt);
1104
+ if (outcome === "connected") return { state: "connected", channelName: attempt.status.channelName ?? attempt.credentials.roomId, channels: refs().map((ref) => ref.roomId) };
1105
+ if (outcome === "failed") {
1023
1106
  if (closed) throw new KhalaClientError("not_connected");
1024
- const attempt = {
1025
- link,
1026
- created: { origin: "", joinId: "", pollSecret: "", confirmUrl: "", expiresAt: "" },
1027
- controller: new AbortController(),
1028
- task: Promise.resolve(),
1029
- joined: false,
1030
- status: { state: "joining", updatedAt: now().toISOString() },
1031
- appends: Promise.resolve(),
1032
- acceptEventKey: createEventKeyFilter()
1033
- };
1034
- attempts.set(link, attempt);
1035
- try {
1036
- const created = await api.requestJoin({ link, harness: options.harness, label: label2, ...rejoinSecret === void 0 ? {} : { sessionId: options.sessionId, rejoinSecret } }, fetchDeps);
1037
- if (closed) throw new KhalaClientError("not_connected");
1038
- attempt.created = created;
1039
- const { joinId, pollSecret, confirmUrl, expiresAt } = created;
1040
- await writeJoinFile(filesForDir(dir), link, { joinId, pollSecret, confirmUrl, expiresAt, link });
1041
- await writeAggregate();
1042
- attempt.task = background(attempt).catch(() => {
1107
+ throw attempt.failure ?? new KhalaClientError("internal_error", attempt.status.detail ?? "internal_error");
1108
+ }
1109
+ return { state: "awaiting_confirmation", confirmUrl: attempt.created.confirmUrl, autoConfirmed: true };
1110
+ });
1111
+ joins.set(link, task);
1112
+ const release = () => {
1113
+ if (joins.get(link) === task) joins.delete(link);
1114
+ };
1115
+ void task.then(release, release);
1116
+ return task;
1117
+ }
1118
+ return {
1119
+ join,
1120
+ resume() {
1121
+ return resuming ??= (async () => {
1122
+ await initialize();
1123
+ if (closed || !rejoinable) return;
1124
+ await Promise.all(refs().map(async (ref) => {
1125
+ const authorization = await readStateFile(ref.files.dir, "resume.json");
1126
+ if (!authorization) return;
1127
+ const previous = priorStatuses.get(ref.key);
1128
+ if (terminal.includes(previous?.detail ?? "")) {
1129
+ await removeStateFile(ref.files.dir, "resume.json");
1130
+ return;
1131
+ }
1132
+ if (!savedSecret || authorization.secretHash !== secretHash() || authorization.workspace !== workspace || authorization.roomId !== ref.roomId || authorization.localCredentials && (authorization.localCredentials.transport !== "local" || authorization.localCredentials.roomId !== ref.roomId) || typeof authorization.link !== "string" || typeof authorization.label !== "string") {
1133
+ await removeStateFile(ref.files.dir, "resume.json");
1134
+ return;
1135
+ }
1136
+ void join(authorization.link, authorization.label, authorization).catch(() => {
1043
1137
  });
1044
- if (created.autoConfirmed !== true) return { state: "awaiting_confirmation", confirmUrl };
1045
- } catch (error) {
1046
- attempts.delete(link);
1047
- if (!closed) await writeAggregate(safeError(error).message);
1048
- throw safeError(error);
1049
- }
1050
- const outcome = await settleAutoConfirmed(attempt);
1051
- if (outcome === "connected") return { state: "connected", channelName: attempt.status.channelName ?? attempt.credentials.roomId, channels: refs().map((ref) => ref.roomId) };
1052
- if (outcome === "failed") {
1053
- if (closed) throw new KhalaClientError("not_connected");
1054
- throw attempt.failure ?? new KhalaClientError("internal_error", attempt.status.detail ?? "internal_error");
1055
- }
1056
- return { state: "awaiting_confirmation", confirmUrl: attempt.created.confirmUrl, autoConfirmed: true };
1057
- });
1058
- joins.set(link, task);
1059
- const release = () => {
1060
- if (joins.get(link) === task) joins.delete(link);
1061
- };
1062
- void task.then(release, release);
1063
- return task;
1138
+ }));
1139
+ })();
1064
1140
  },
1065
1141
  async status(channel) {
1066
1142
  await initialize();
@@ -1201,7 +1277,7 @@ function createKhalaAgentClient(options) {
1201
1277
  })));
1202
1278
  await Promise.all([...attempts.values()].map(async (attempt) => {
1203
1279
  await cancel(attempt);
1204
- await setStatus(attempt, "disconnected", "closed");
1280
+ await setStatus(attempt, "disconnected", terminal.includes(attempt.status.detail ?? "") ? attempt.status.detail : "closed");
1205
1281
  await removeJoinFile(filesForDir(dir), attempt.link);
1206
1282
  }));
1207
1283
  await removeStateFile(dir, "session.json");
@@ -1212,210 +1288,24 @@ function createKhalaAgentClient(options) {
1212
1288
  };
1213
1289
  }
1214
1290
 
1215
- // src/wake/codex.ts
1216
- import path from "node:path";
1217
-
1218
- // src/wake/idle-wake.ts
1219
- var CODEX_IDLE_WAKE_NOTICE = "Khala: channel messages are waiting. Continue.";
1220
- function codexIdleWakeArgv(sessionId) {
1221
- return ["queue", "--thread", sessionId, "--message", CODEX_IDLE_WAKE_NOTICE];
1222
- }
1223
-
1224
- // src/wake/idle-wake-process.ts
1225
- import { spawn as nodeSpawn } from "node:child_process";
1226
- var CODEX_QUEUE_ENV_ALLOWLIST = [
1227
- "PATH",
1228
- "HOME",
1229
- "USER",
1230
- "LOGNAME",
1231
- "SHELL",
1232
- "LANG",
1233
- "LC_ALL",
1234
- "TMPDIR",
1235
- "CODEX_HOME",
1236
- "XDG_CONFIG_HOME",
1237
- "XDG_DATA_HOME",
1238
- "XDG_STATE_HOME",
1239
- "XDG_RUNTIME_DIR"
1240
- ];
1241
- function scrubbedQueueEnv(source) {
1242
- const env = {};
1243
- for (const name of CODEX_QUEUE_ENV_ALLOWLIST) {
1244
- const value = source[name];
1245
- if (value !== void 0) env[name] = value;
1246
- }
1247
- return env;
1248
- }
1249
- function createCodexQueueProcessPort(deps) {
1250
- const spawn = deps.spawn ?? ((command, argv, options) => nodeSpawn(command, [...argv], options));
1251
- const timeoutMs = deps.timeoutMs ?? 1e4;
1252
- return {
1253
- run(argv, signal) {
1254
- return new Promise((resolve) => {
1255
- if (signal.aborted) return resolve({ status: "not_started" });
1256
- let child;
1257
- try {
1258
- child = spawn(deps.command, argv, { shell: false, stdio: "ignore", env: scrubbedQueueEnv(deps.env) });
1259
- } catch {
1260
- return resolve({ status: "not_started" });
1261
- }
1262
- let timedOut = false;
1263
- let stopped = false;
1264
- let force = null;
1265
- const stop = () => {
1266
- if (stopped) return;
1267
- stopped = true;
1268
- child.kill("SIGTERM");
1269
- force = setTimeout(() => {
1270
- child.kill("SIGKILL");
1271
- }, 500);
1272
- };
1273
- const timer = setTimeout(() => {
1274
- timedOut = true;
1275
- stop();
1276
- }, timeoutMs);
1277
- signal.addEventListener("abort", stop, { once: true });
1278
- const settle = (outcome) => {
1279
- clearTimeout(timer);
1280
- if (force !== null) clearTimeout(force);
1281
- signal.removeEventListener("abort", stop);
1282
- resolve(outcome);
1283
- };
1284
- child.on("error", () => settle({ status: "not_started" }));
1285
- child.on("close", (code) => settle(timedOut ? { status: "lost", cause: "timeout" } : code === 0 ? { status: "queued" } : { status: "exited", code: code ?? -1 }));
1286
- });
1287
- }
1288
- };
1289
- }
1290
-
1291
- // src/wake/codex.ts
1292
- function createCodexWaker(deps) {
1293
- if (!SESSION_ID_PATTERN.test(deps.threadId)) throw new TypeError("invalid_thread_id");
1294
- const port = deps.port ?? createCodexQueueProcessPort({ command: "codex", env: process.env });
1295
- const now = deps.now ?? Date.now;
1296
- const stderr = deps.stderr ?? ((line) => {
1297
- process.stderr.write(line);
1298
- });
1299
- const controller = new AbortController();
1300
- let pending;
1301
- const wakesAtCount = /* @__PURE__ */ new Map();
1302
- const identities = /* @__PURE__ */ new Map();
1303
- let stopped = false;
1304
- let again = false;
1305
- let inFlight;
1306
- const warn = (warning, status) => {
1307
- try {
1308
- stderr(JSON.stringify({ ok: false, warning, ...status ? { status } : {} }) + "\n");
1309
- } catch {
1310
- }
1311
- };
1312
- const evaluate = async () => {
1313
- try {
1314
- const channels = await listChannels(deps.files);
1315
- const eligible = [];
1316
- const currentIdentities = /* @__PURE__ */ new Map();
1317
- for (const channel of channels) {
1318
- const session = await readJson(channel.files.session);
1319
- const metadata = channel.legacy ? null : await readJson(path.join(channel.files.dir, "channel.json"));
1320
- currentIdentities.set(channel.key, JSON.stringify([session?.roomId ?? channel.roomId, session?.userId, metadata?.joinedAt]));
1321
- if (await readListeningMode(channel.files) === "async") continue;
1322
- eligible.push({
1323
- channel,
1324
- counts: await unreadCount(channel.files.dir),
1325
- cursor: await readCursor(channel.files)
1326
- });
1327
- }
1328
- const keys = new Set(currentIdentities.keys());
1329
- const changed = /* @__PURE__ */ new Set();
1330
- for (const [key, identity] of currentIdentities) {
1331
- if (identities.get(key) !== identity) changed.add(key);
1332
- }
1333
- for (const key of identities.keys()) if (!keys.has(key)) changed.add(key);
1334
- if (changed.size) {
1335
- for (const [key, wakes2] of [...wakesAtCount]) {
1336
- const pairs2 = JSON.parse(key);
1337
- if (!pairs2.some(([id]) => changed.has(id))) continue;
1338
- wakesAtCount.delete(key);
1339
- const remainingPairs = pairs2.filter(([id]) => !changed.has(id));
1340
- if (!remainingPairs.length) continue;
1341
- const remaining = JSON.stringify(remainingPairs);
1342
- wakesAtCount.set(remaining, Math.max(wakes2, wakesAtCount.get(remaining) ?? 0));
1343
- }
1344
- }
1345
- for (const key of changed) identities.delete(key);
1346
- for (const [key, identity] of currentIdentities) identities.set(key, identity);
1347
- const pairs = eligible.map(({ channel, cursor }) => [channel.key, cursor.deliveredCount]).sort(([a], [b]) => a.localeCompare(b));
1348
- const budgetKey = JSON.stringify(pairs);
1349
- if (changed.size) {
1350
- const inheritedKey = JSON.stringify(pairs.filter(([key]) => !changed.has(key)));
1351
- const newUnread = eligible.some((item) => changed.has(item.channel.key) && item.counts.messages > 0);
1352
- wakesAtCount.set(budgetKey, newUnread ? 0 : wakesAtCount.get(inheritedKey) ?? 0);
1353
- if (newUnread || !eligible.some((item) => !changed.has(item.channel.key) && item.counts.messages > 0)) pending = void 0;
1354
- }
1355
- if (!eligible.some((item) => item.counts.messages > 0)) {
1356
- pending = void 0;
1357
- return;
1358
- }
1359
- const activity = await readActivity(deps.files);
1360
- if (pending && Date.parse(activity.updatedAt) > pending.at) pending = void 0;
1361
- const at = now();
1362
- if (pending && at - pending.at < (deps.retryAfterMs ?? 6e4)) return;
1363
- if (stopped || activity.state !== "idle") return;
1364
- const wakes = wakesAtCount.get(budgetKey) ?? 0;
1365
- if (wakes >= 2) return;
1366
- for (const { channel } of eligible) {
1367
- if (await readListeningMode(channel.files) === "async") {
1368
- pending = void 0;
1369
- return;
1370
- }
1371
- }
1372
- if (stopped) return;
1373
- pending = { at };
1374
- wakesAtCount.set(budgetKey, wakes + 1);
1375
- const outcome = await port.run(codexIdleWakeArgv(deps.threadId), controller.signal);
1376
- if (outcome.status !== "queued") warn("codex_queue_failed", outcome.status);
1377
- } catch {
1378
- warn("codex_waker_error");
1379
- }
1380
- };
1381
- const notify = () => {
1382
- if (stopped) return;
1383
- if (inFlight) {
1384
- again = true;
1385
- return;
1386
- }
1387
- inFlight = (async () => {
1388
- do {
1389
- again = false;
1390
- await evaluate();
1391
- } while (again && !stopped);
1392
- })().finally(() => {
1393
- inFlight = void 0;
1394
- if (again && !stopped) notify();
1395
- });
1396
- };
1397
- const timer = setInterval(notify, deps.pollMs ?? 1e3);
1398
- timer.unref();
1399
- return {
1400
- notify,
1401
- async stop() {
1402
- stopped = true;
1403
- again = false;
1404
- clearInterval(timer);
1405
- controller.abort();
1406
- await inFlight;
1407
- }
1408
- };
1409
- }
1410
-
1411
1291
  // src/mcp/wiring.ts
1412
1292
  function createRealClientFactory(env, deps = {}) {
1413
- return ({ harness, sessionId }) => {
1293
+ return ({ harness, sessionId, rejoinable }) => {
1414
1294
  const files = sessionFiles(harness, sessionId, env);
1415
- const waker = harness === "codex" ? (deps.createWaker ?? createCodexWaker)({ files, threadId: sessionId }) : void 0;
1295
+ const adapter = adapterFor(harness);
1296
+ const drivers = adapter?.wakeLadder;
1297
+ const waker = drivers?.length ? deps.createWaker ? deps.createWaker({ files, threadId: sessionId }) : createWakeLadder({
1298
+ files,
1299
+ harness,
1300
+ sessionId,
1301
+ drivers,
1302
+ env,
1303
+ warningPrefix: adapter?.wakeWarningName ?? "wake"
1304
+ }) : void 0;
1416
1305
  const client = (deps.createClient ?? createKhalaAgentClient)({
1417
1306
  harness,
1418
1307
  sessionId,
1308
+ ...rejoinable !== void 0 ? { rejoinable } : {},
1419
1309
  env,
1420
1310
  ...waker ? { onInboxAppend: () => waker.notify() } : {}
1421
1311
  });
@@ -1426,7 +1316,10 @@ function createRealClientFactory(env, deps = {}) {
1426
1316
  const joinsDir = path2.join(files.dir, "joins");
1427
1317
  await ensureStateDir(joinsDir);
1428
1318
  await fs2.rm(joinsDir, { recursive: true, force: true });
1319
+ await client.resume?.();
1429
1320
  })();
1321
+ void initialize().catch(() => {
1322
+ });
1430
1323
  return {
1431
1324
  async join(link, label2) {
1432
1325
  await initialize();
@@ -1435,7 +1328,7 @@ function createRealClientFactory(env, deps = {}) {
1435
1328
  async status(channel) {
1436
1329
  await initialize();
1437
1330
  const status = await (channel === void 0 ? client.status() : client.status(channel));
1438
- return harness === "claude" && ["connected", "send_failed"].includes(status.state) ? { ...status, watcherArmed: await monitorArmed(files) } : status;
1331
+ return adapter?.watcherStatus && ["connected", "send_failed"].includes(status.state) ? { ...status, watcherArmed: await monitorArmed(files) } : status;
1439
1332
  },
1440
1333
  async read(limit, before, channel) {
1441
1334
  await initialize();
@@ -1507,17 +1400,23 @@ async function runMcpCommand(argv, deps) {
1507
1400
  return 2;
1508
1401
  }
1509
1402
  const clients = /* @__PURE__ */ new Map();
1403
+ const clientForSession = (session) => {
1404
+ const { sessionId, rejoinable } = session;
1405
+ const key = `${rejoinable}:${sessionId}`;
1406
+ let client = clients.get(key);
1407
+ if (client === void 0) {
1408
+ client = deps.createClient({ harness, sessionId, rejoinable });
1409
+ clients.set(key, client);
1410
+ }
1411
+ return client;
1412
+ };
1413
+ const startupSession = adapterFor(harness)?.restoreAtStartup ? await resolveSession(harness, void 0, env) : null;
1414
+ if (startupSession !== null) clientForSession(startupSession);
1510
1415
  const tools = createKhalaTools({
1511
1416
  harness,
1512
- clientFor(meta) {
1513
- const sessionId = resolveSessionId(harness, meta, env);
1514
- if (sessionId === null) return null;
1515
- let client = clients.get(sessionId);
1516
- if (client === void 0) {
1517
- client = deps.createClient({ harness, sessionId });
1518
- clients.set(sessionId, client);
1519
- }
1520
- return client;
1417
+ async clientFor(meta) {
1418
+ const session = await resolveSession(harness, meta, env);
1419
+ return session === null ? null : clientForSession(session);
1521
1420
  }
1522
1421
  });
1523
1422
  try {