khala-cli 0.3.0 → 0.4.1

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.
@@ -4,16 +4,17 @@ import {
4
4
  MODEL_NAMES,
5
5
  checkName,
6
6
  isDefaultAgentName
7
- } from "./chunk-6GOETMWV.mjs";
7
+ } from "./chunk-AVJKIJ34.mjs";
8
8
  import {
9
9
  StateError,
10
10
  readJson,
11
11
  stateRoot,
12
12
  writeJsonAtomic
13
- } from "./chunk-S6XASSNU.mjs";
13
+ } from "./chunk-B5BFL7OD.mjs";
14
14
  import {
15
+ HARNESSES,
15
16
  validateAgentName
16
- } from "./chunk-GO5DD2TR.mjs";
17
+ } from "./chunk-N6LMHR7P.mjs";
17
18
 
18
19
  // src/local/identity.ts
19
20
  import * as fs from "node:fs/promises";
@@ -36,7 +37,7 @@ async function ensurePrivateRoot(root) {
36
37
  if (error.code !== "EEXIST") throw error;
37
38
  }
38
39
  const stat = await fs.lstat(root);
39
- if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 63) !== 0 || typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new StateError("unsafe_state_dir");
40
+ if (!stat.isDirectory() || stat.isSymbolicLink() || process.platform !== "win32" && (stat.mode & 63) !== 0 || typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new StateError("unsafe_state_dir");
40
41
  } catch (error) {
41
42
  if (error instanceof StateError) throw error;
42
43
  throw new StateError("storage_failed");
@@ -138,13 +139,21 @@ async function post(url, origin, body, deps, pollSecret) {
138
139
  async function requestJoin(input, deps = {}) {
139
140
  const parsed = parseChannelLink(input.link);
140
141
  if (!parsed) throw new KhalaClientError("invalid_link", "invalid_link");
141
- if (input.harness !== "claude" && input.harness !== "codex") fail("invalid_harness");
142
+ if (!HARNESSES.includes(input.harness)) fail("invalid_harness");
142
143
  const label = validateAgentName(input.label);
143
144
  if (!label.ok || [...label.name].length > 40) fail("invalid_label");
144
145
  const { origin } = parsed;
145
- const response = await post(new URL("/api/agent/join", origin), origin, { link: input.link, harness: input.harness, label: label.name }, deps);
146
- const body = await json(response);
147
- const code = serverCode(body);
146
+ const url = new URL("/api/agent/join", origin);
147
+ const base = { link: input.link, harness: input.harness, label: label.name };
148
+ const rejoin = input.sessionId !== void 0 && input.rejoinSecret !== void 0;
149
+ let response = await post(url, origin, rejoin ? { ...base, sessionId: input.sessionId, rejoinSecret: input.rejoinSecret } : base, deps);
150
+ let body = await json(response);
151
+ let code = serverCode(body);
152
+ if (rejoin && response.status === 400 && code === "invalid_link") {
153
+ response = await post(url, origin, base, deps);
154
+ body = await json(response);
155
+ code = serverCode(body);
156
+ }
148
157
  if (response.status !== 201) {
149
158
  if (response.status === 400 && code === "invalid_link") throw new KhalaClientError("invalid_link", "invalid_link");
150
159
  if (response.status === 400 && (code === "invalid_label" || code === "invalid_harness")) fail(code);
@@ -212,7 +212,7 @@ function validateAgentName(input) {
212
212
  }
213
213
 
214
214
  // ../contracts/src/m1/agent-join.ts
215
- var HARNESSES = ["claude", "codex"];
215
+ var HARNESSES = ["claude", "codex", "cursor"];
216
216
  var M1_LABEL_MAX_BYTES = 512;
217
217
  var agentConfirmPagePath = (id) => `/agent/confirm?joinId=${encodeURIComponent(id)}`;
218
218
  function readHarness(input, path) {
@@ -239,6 +239,8 @@ function readRoomId(input, path) {
239
239
  if (!/^!\S+$/u.test(value)) fail2(path, "invalid_value");
240
240
  return value;
241
241
  }
242
+ var validAgentSessionId = (value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u.test(value);
243
+ var validAgentRejoinSecret = (value) => typeof value === "string" && /^[A-Za-z0-9_-]{43}$/u.test(value);
242
244
 
243
245
  // ../contracts/src/m1/listening-mode.ts
244
246
  var LISTENING_MODE_COMMAND_TYPE = "com.khala.listening_mode.v1";
@@ -381,6 +383,13 @@ function encodeChannelEvent(input) {
381
383
  return decodeChannelEvent({ ...decoded.value, body: formatChannelEventLine(decoded.value) });
382
384
  });
383
385
  }
386
+ function memberRenameContent(content, previous) {
387
+ const oldName = previous?.["displayname"];
388
+ const newName = content["displayname"];
389
+ if (previous?.["membership"] !== "join" || content["membership"] !== "join" || typeof oldName !== "string" || !oldName.trim() || typeof newName !== "string" || !newName.trim() || oldName === newName) return null;
390
+ const encoded = encodeChannelEvent({ kind: "member", summary: `${oldName} is now ${newName}`, status: "info", source: { system: "khala" } });
391
+ return encoded.ok ? encoded.value : null;
392
+ }
384
393
  function statusFor(content) {
385
394
  if (content.status !== void 0) return content.status;
386
395
  switch (content.kind) {
@@ -403,8 +412,6 @@ function statusFor(content) {
403
412
  }
404
413
 
405
414
  export {
406
- LISTENING_MODES,
407
- decodeListeningMode,
408
415
  fail2 as fail,
409
416
  decodeWith2 as decodeWith,
410
417
  object2 as object,
@@ -423,12 +430,17 @@ export {
423
430
  utcTimestamp2 as utcTimestamp,
424
431
  reserved,
425
432
  validateAgentName,
433
+ HARNESSES,
426
434
  M1_LABEL_MAX_BYTES,
427
435
  agentConfirmPagePath,
428
436
  readHarness,
429
437
  readHttpUrl,
430
438
  readMatrixUserId,
431
439
  readRoomId,
440
+ validAgentSessionId,
441
+ validAgentRejoinSecret,
442
+ LISTENING_MODES,
443
+ decodeListeningMode,
432
444
  LISTENING_MODE_COMMAND_TYPE,
433
445
  LISTENING_MODE_MEMBER_KEY,
434
446
  DEFAULT_LISTENING_MODE,
@@ -438,5 +450,6 @@ export {
438
450
  decodeChannelEvent,
439
451
  formatChannelEventLine,
440
452
  encodeChannelEvent,
453
+ memberRenameContent,
441
454
  statusFor
442
455
  };
@@ -1,12 +1,15 @@
1
1
  import { createRequire as __khalaCreateRequire } from 'node:module'; const require = __khalaCreateRequire(import.meta.url);
2
+ import {
3
+ CURSOR_WORKSPACE_ENV
4
+ } from "./chunk-VUE2DDYY.mjs";
2
5
  import {
3
6
  bundle
4
- } from "./chunk-HMGN52QY.mjs";
5
- import "./chunk-6FNXMYOM.mjs";
7
+ } from "./chunk-HAD5ZILW.mjs";
8
+ import "./chunk-SR5BG7FL.mjs";
6
9
 
7
10
  // src/install/main.ts
8
11
  import { spawnSync } from "node:child_process";
9
- import * as fs from "node:fs/promises";
12
+ import * as fs2 from "node:fs/promises";
10
13
  import os from "node:os";
11
14
  import path from "node:path";
12
15
 
@@ -71,9 +74,159 @@ function mergeCodexHooks(config, action, hooksFragment = codexHooksFragment()) {
71
74
  return { config, warnings };
72
75
  }
73
76
 
77
+ // src/install/cursor.ts
78
+ import * as fs from "node:fs/promises";
79
+ var CURSOR_HOOK_EVENTS = ["beforeSubmitPrompt", "postToolUse", "stop"];
80
+ var HOOK_SUFFIX = " hook deliver --harness cursor";
81
+ function cursorPaths({ platform, path: path2, home, env }, packageName = "khala-cli") {
82
+ const cursorDir = path2.join(home, ".cursor");
83
+ const windows = platform === "win32";
84
+ const dataHome = windows ? env.LOCALAPPDATA && path2.isAbsolute(env.LOCALAPPDATA) ? env.LOCALAPPDATA : path2.join(home, "AppData", "Local") : env.XDG_DATA_HOME && path2.isAbsolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : path2.join(home, ".local", "share");
85
+ const prefix = path2.join(dataHome, "khala", "npm");
86
+ const modules = windows ? path2.join(prefix, "node_modules") : path2.join(prefix, "lib", "node_modules");
87
+ return {
88
+ cursorDir,
89
+ prefix,
90
+ mcpFile: path2.join(cursorDir, "mcp.json"),
91
+ hooksFile: path2.join(cursorDir, "hooks.json"),
92
+ script: path2.join(modules, ...packageName.split("/"), "dist", "khala.mjs")
93
+ };
94
+ }
95
+ var posixQuote = (value) => /^[A-Za-z0-9_./:-]+$/u.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
96
+ function cursorHookCommand(platform, node, script) {
97
+ if (platform === "win32") return `node "${script.replaceAll("\\", "/")}"${HOOK_SUFFIX}`;
98
+ return `${posixQuote(node)} ${posixQuote(script)}${HOOK_SUFFIX}`;
99
+ }
100
+ function cursorMcpEntry(node, script) {
101
+ return {
102
+ type: "stdio",
103
+ command: node,
104
+ args: [script, "mcp", "--harness", "cursor"],
105
+ env: { [CURSOR_WORKSPACE_ENV]: "${workspaceFolder}" }
106
+ };
107
+ }
108
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
109
+ function isKhalaCursorServer(entry) {
110
+ if (!isObject(entry) || !Array.isArray(entry.args)) return false;
111
+ const args = entry.args;
112
+ const at = args.indexOf("--harness");
113
+ return args.includes("mcp") && at !== -1 && args[at + 1] === "cursor";
114
+ }
115
+ function mergeCursorMcp(config, entry) {
116
+ if (!isObject(config)) return { error: "invalid_config" };
117
+ const servers = config.mcpServers ?? {};
118
+ if (!isObject(servers)) return { error: "invalid_config" };
119
+ if (Object.hasOwn(servers, "khala") && !isKhalaCursorServer(servers.khala)) return { error: "cursor_mcp_exists" };
120
+ const next = { ...servers };
121
+ delete next.khala;
122
+ if (entry) next.khala = entry;
123
+ return { config: { ...config, mcpServers: next } };
124
+ }
125
+ function mergeCursorHooks(config, command) {
126
+ if (!isObject(config)) return { error: "invalid_config" };
127
+ const hooks = config.hooks ?? {};
128
+ if (!isObject(hooks) || Object.values(hooks).some((list) => !Array.isArray(list) || list.some((item) => !isObject(item)))) return { error: "invalid_config" };
129
+ const ours = (item) => typeof item.command === "string" && item.command.endsWith(HOOK_SUFFIX);
130
+ const next = {};
131
+ for (const [event, list] of Object.entries(hooks)) {
132
+ const kept = list.filter((item) => !ours(item));
133
+ if (kept.length) next[event] = kept;
134
+ }
135
+ if (command) {
136
+ for (const event of CURSOR_HOOK_EVENTS) next[event] = [...next[event] ?? [], { command, timeout: 10 }];
137
+ }
138
+ return { config: { version: 1, ...config, hooks: next } };
139
+ }
140
+ async function readJsonFile(file) {
141
+ let text;
142
+ try {
143
+ text = await fs.readFile(file, "utf8");
144
+ } catch (error) {
145
+ if (error.code === "ENOENT") return { text: null, value: {} };
146
+ throw error;
147
+ }
148
+ if (!text.trim()) return { text, value: {} };
149
+ try {
150
+ return { text, value: JSON.parse(text.replace(/^/u, "")) };
151
+ } catch {
152
+ return { text, value: void 0 };
153
+ }
154
+ }
155
+ async function installCursor(input) {
156
+ const { paths, uninstall, stdout, stderr } = input;
157
+ const mcp = await readJsonFile(paths.mcpFile);
158
+ const hooks = await readJsonFile(paths.hooksFile);
159
+ const command = cursorHookCommand(input.platform, input.node, paths.script);
160
+ const nextMcp = mergeCursorMcp(mcp.value, uninstall ? null : cursorMcpEntry(input.node, paths.script));
161
+ if ("error" in nextMcp) {
162
+ stderr(nextMcp.error === "cursor_mcp_exists" ? `khala: ${paths.mcpFile} already has a "khala" server that is not this CLI; remove it and run this again` : `khala: invalid JSON in ${paths.mcpFile}`);
163
+ return 1;
164
+ }
165
+ const nextHooks = mergeCursorHooks(hooks.value, uninstall ? null : command);
166
+ if ("error" in nextHooks) {
167
+ stderr(`khala: invalid JSON in ${paths.hooksFile}`);
168
+ return 1;
169
+ }
170
+ if (!uninstall && input.install && !input.install()) return 1;
171
+ await fs.mkdir(paths.cursorDir, { recursive: true });
172
+ for (const [file, before, after] of [[paths.mcpFile, mcp.text, nextMcp.config], [paths.hooksFile, hooks.text, nextHooks.config]]) {
173
+ if (uninstall && before === null) continue;
174
+ if (!uninstall && before !== null) {
175
+ try {
176
+ await fs.writeFile(file + ".khala-bak", before, { flag: "wx" });
177
+ } catch (error) {
178
+ if (error.code !== "EEXIST") throw error;
179
+ }
180
+ }
181
+ const text = JSON.stringify(after, null, 2) + "\n";
182
+ if (text !== before) await fs.writeFile(file, text);
183
+ }
184
+ if (uninstall) {
185
+ stdout(`removed the Khala MCP server and hooks from ${paths.cursorDir}; delete ${paths.prefix} to remove the CLI`);
186
+ } else {
187
+ stdout(`configured ${paths.mcpFile} and ${paths.hooksFile}`);
188
+ stdout('restart Cursor (or toggle "khala" off and on in Settings > MCP), then check that khala shows its five tools');
189
+ }
190
+ return 0;
191
+ }
192
+
74
193
  // src/install/main.ts
75
194
  var MCP_MARKER = "# Khala MCP server, managed by `khala install codex`";
76
- var USAGE = "usage: khala install codex [--codex-home <dir>] [--uninstall]";
195
+ var USAGE = "usage: khala install codex [--codex-home <dir>] [--uninstall] | khala install cursor [--uninstall]";
196
+ async function runCursorInstall(flags, deps, env, stdout, stderr) {
197
+ let uninstall = false;
198
+ for (const flag of flags) {
199
+ if (flag === "--uninstall") uninstall = true;
200
+ else {
201
+ stderr(USAGE);
202
+ return 1;
203
+ }
204
+ }
205
+ const pkg = "package" in deps ? deps.package : bundle;
206
+ if (!pkg && !uninstall) {
207
+ stderr("khala: install runs from the published package (npx -y khala-cli install cursor)");
208
+ return 1;
209
+ }
210
+ const platform = deps.platform ?? process.platform;
211
+ const pathApi = platform === "win32" ? path.win32 : path.posix;
212
+ const home = deps.home ?? (platform === "win32" ? env.USERPROFILE || os.homedir() : env.HOME || os.homedir());
213
+ const paths = cursorPaths({ platform, path: pathApi, home, env }, pkg?.name);
214
+ const spec = pkg ? env.KHALA_INSTALL_SPEC || `${pkg.name}@${pkg.version}` : "";
215
+ return installCursor({
216
+ paths,
217
+ platform,
218
+ node: deps.node ?? process.execPath,
219
+ uninstall,
220
+ stdout,
221
+ stderr,
222
+ install: () => {
223
+ stdout(`installing ${spec} into ${paths.prefix}`);
224
+ if ((deps.npmInstall ?? defaultNpmInstall)(paths.prefix, spec)) return true;
225
+ stderr("khala: npm install failed for " + spec);
226
+ return false;
227
+ }
228
+ });
229
+ }
77
230
  function shellQuote(value) {
78
231
  return /^[A-Za-z0-9_./-]+$/u.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
79
232
  }
@@ -113,7 +266,7 @@ function codexMcpBlock(bin, env) {
113
266
  }
114
267
  async function readOr(file, fallback) {
115
268
  try {
116
- return { text: await fs.readFile(file, "utf8"), mode: (await fs.stat(file)).mode & 511 };
269
+ return { text: await fs2.readFile(file, "utf8"), mode: (await fs2.stat(file)).mode & 511 };
117
270
  } catch (error) {
118
271
  if (error.code === "ENOENT") return { text: fallback, mode: 384 };
119
272
  throw error;
@@ -128,6 +281,7 @@ async function runInstall(argv, deps = {}) {
128
281
  process.stderr.write(line + "\n");
129
282
  });
130
283
  const [target, ...flags] = argv;
284
+ if (target === "cursor") return runCursorInstall(flags, deps, env, stdout, stderr);
131
285
  let codexHome = env.CODEX_HOME && path.isAbsolute(env.CODEX_HOME) ? env.CODEX_HOME : path.join(env.HOME ?? os.homedir(), ".codex");
132
286
  let uninstall = false;
133
287
  for (let i = 0; i < flags.length; i++) {
@@ -182,17 +336,17 @@ async function runInstall(argv, deps = {}) {
182
336
  return 1;
183
337
  }
184
338
  }
185
- await fs.mkdir(codexHome, { recursive: true });
339
+ await fs2.mkdir(codexHome, { recursive: true });
186
340
  if (!uninstall) {
187
341
  try {
188
- await fs.writeFile(hooksFile + ".khala-bak", hooks.text, { flag: "wx", mode: hooks.mode });
342
+ await fs2.writeFile(hooksFile + ".khala-bak", hooks.text, { flag: "wx", mode: hooks.mode });
189
343
  } catch (error) {
190
344
  if (error.code !== "EEXIST") throw error;
191
345
  }
192
346
  }
193
347
  for (const warning of merged.warnings) stderr(warning);
194
- await fs.writeFile(hooksFile, JSON.stringify(merged.config, null, 2) + "\n", { mode: hooks.mode });
195
- await fs.writeFile(tomlFile, nextToml.text, { mode: toml.mode });
348
+ await fs2.writeFile(hooksFile, JSON.stringify(merged.config, null, 2) + "\n", { mode: hooks.mode });
349
+ await fs2.writeFile(tomlFile, nextToml.text, { mode: toml.mode });
196
350
  if (uninstall) {
197
351
  stdout(`removed the Khala MCP server and hooks from ${codexHome}; delete ${prefix} to remove the CLI`);
198
352
  } else {
@@ -20,14 +20,14 @@ import {
20
20
  newLocalEventId,
21
21
  newLocalRoomId,
22
22
  readHelperFile
23
- } from "./chunk-ZRUF3P53.mjs";
23
+ } from "./chunk-3SWCRLWX.mjs";
24
24
  import {
25
25
  parseChannelLink,
26
26
  resolveLocalOwnerName
27
- } from "./chunk-UYTGV6TW.mjs";
27
+ } from "./chunk-L2HRDTA5.mjs";
28
28
  import {
29
29
  KHALA_AGENT_VERSION
30
- } from "./chunk-Y3TDVOGT.mjs";
30
+ } from "./chunk-YMDAY234.mjs";
31
31
  import {
32
32
  checkName,
33
33
  defaultAgentName,
@@ -35,16 +35,18 @@ import {
35
35
  isDefaultAgentName,
36
36
  isHumanColorId,
37
37
  normalizeInitials
38
- } from "./chunk-6GOETMWV.mjs";
38
+ } from "./chunk-AVJKIJ34.mjs";
39
39
  import {
40
40
  StateError,
41
41
  ensureStateDir,
42
42
  readJson,
43
43
  writeJsonAtomic
44
- } from "./chunk-S6XASSNU.mjs";
44
+ } from "./chunk-B5BFL7OD.mjs";
45
+ import "./chunk-5GLTKWJ5.mjs";
45
46
  import {
46
47
  CHANNEL_EVENT_TYPE,
47
48
  DEFAULT_LISTENING_MODE,
49
+ HARNESSES,
48
50
  LISTENING_MODE_COMMAND_TYPE,
49
51
  LISTENING_MODE_MEMBER_KEY,
50
52
  agentConfirmPagePath,
@@ -54,13 +56,16 @@ import {
54
56
  decodeWith,
55
57
  encodeChannelEvent,
56
58
  memberListeningMode,
59
+ memberRenameContent,
57
60
  object,
58
- utf8Length
59
- } from "./chunk-GO5DD2TR.mjs";
61
+ utf8Length,
62
+ validAgentRejoinSecret,
63
+ validAgentSessionId
64
+ } from "./chunk-N6LMHR7P.mjs";
60
65
  import {
61
66
  bundle
62
- } from "./chunk-HMGN52QY.mjs";
63
- import "./chunk-6FNXMYOM.mjs";
67
+ } from "./chunk-HAD5ZILW.mjs";
68
+ import "./chunk-SR5BG7FL.mjs";
64
69
 
65
70
  // src/local/cli.ts
66
71
  import { setTimeout as sleepDefault } from "node:timers/promises";
@@ -111,6 +116,7 @@ async function openLocalStore(input) {
111
116
  byEventId: /* @__PURE__ */ new Map(),
112
117
  byTxn: /* @__PURE__ */ new Map(),
113
118
  lastMember: /* @__PURE__ */ new Map(),
119
+ previousMembers: /* @__PURE__ */ new Map(),
114
120
  secrets: emptySecrets(),
115
121
  waiters: /* @__PURE__ */ new Set()
116
122
  };
@@ -125,6 +131,8 @@ async function openLocalStore(input) {
125
131
  } else if (event.type === "m.room.name") channel.name = event.content.name;
126
132
  else if (event.type === "m.room.member") {
127
133
  const user = event.content.user;
134
+ const previous = channel.lastMember.get(user)?.event.content;
135
+ if (previous) channel.previousMembers.set(event.eventId, previous);
128
136
  const firstSeq = channel.lastMember.get(user)?.firstSeq ?? event.seq;
129
137
  channel.lastMember.set(user, { event, firstSeq });
130
138
  }
@@ -379,7 +387,11 @@ async function openLocalStore(input) {
379
387
  if (events[mid].seq <= after) lo = mid + 1;
380
388
  else hi = mid;
381
389
  }
382
- return structuredClone(events.slice(lo, lo + Math.max(0, limit)));
390
+ const channel = channels.get(roomId);
391
+ return structuredClone(events.slice(lo, lo + Math.max(0, limit)).map((event) => {
392
+ const previousContent = channel?.previousMembers.get(event.eventId);
393
+ return previousContent ? { ...event, previousContent } : event;
394
+ }));
383
395
  },
384
396
  waitForEvent: (roomId, after, timeoutMs, signal) => {
385
397
  const channel = channels.get(roomId);
@@ -389,7 +401,17 @@ async function openLocalStore(input) {
389
401
  const channel = channels.get(roomId);
390
402
  const cutoff = before !== void 0 ? channel?.byEventId.get(before)?.seq : Infinity;
391
403
  if (!channel || cutoff === void 0 || limit <= 0) return { events: [] };
392
- const candidates = channel.events.filter((e) => e.seq < cutoff && (e.type === "m.room.message" || e.type === "com.khala.event.v1"));
404
+ const previousMembers = /* @__PURE__ */ new Map();
405
+ const candidates = [];
406
+ for (const event of channel.events) {
407
+ if (event.seq >= cutoff) break;
408
+ if (event.type === "m.room.member") {
409
+ const user = event.content.user;
410
+ const content = memberRenameContent(event.content, previousMembers.get(user));
411
+ previousMembers.set(user, event.content);
412
+ if (content) candidates.push({ ...event, type: "com.khala.event.v1", content });
413
+ } else if (event.type === "m.room.message" || event.type === "com.khala.event.v1") candidates.push(event);
414
+ }
393
415
  const events = candidates.slice(-limit);
394
416
  return { events: structuredClone(events), ...candidates.length > events.length ? { nextBefore: events[0].eventId } : {} };
395
417
  },
@@ -421,11 +443,20 @@ async function openLocalStore(input) {
421
443
  }
422
444
  return null;
423
445
  }),
424
- setMemberToken: (roomId, userId, tokenSha256) => enqueue(async () => {
446
+ memberForSession: (roomId, sessionKey) => {
447
+ const channel = channels.get(roomId);
448
+ if (!channel) return void 0;
449
+ const userId = Object.entries(channel.secrets.members).find(([, secret]) => secret.sessionKey === sessionKey)?.[0];
450
+ return userId ? member(channel, userId) : void 0;
451
+ },
452
+ setMemberToken: (roomId, userId, tokenSha256, sessionKey) => enqueue(async () => {
425
453
  const channel = requireChannel(roomId);
426
454
  const next = { ...channel.secrets.members };
427
455
  if (tokenSha256 === null) delete next[userId];
428
- else if (userId !== LOCAL_OWNER_USER_ID) next[userId] = { tokenSha256 };
456
+ else if (userId !== LOCAL_OWNER_USER_ID) {
457
+ const identity = sessionKey ?? channel.secrets.members[userId]?.sessionKey;
458
+ next[userId] = { tokenSha256, ...identity ? { sessionKey: identity } : {} };
459
+ }
429
460
  await saveSecrets(channel, { ...channel.secrets, members: next });
430
461
  }),
431
462
  agentForToken: (token) => {
@@ -778,10 +809,12 @@ function agentJoinRoutes() {
778
809
  prune(ctx);
779
810
  if (typeof req.body !== "object" || req.body === null || Array.isArray(req.body)) return fail(400, "invalid_link");
780
811
  const body = req.body;
781
- if (!Object.hasOwn(body, "link") || Object.keys(body).some((key) => !["link", "harness", "label"].includes(key))) return fail(400, "invalid_link");
812
+ if (!Object.hasOwn(body, "link") || Object.keys(body).some((key) => !["link", "harness", "label", "sessionId", "rejoinSecret"].includes(key))) return fail(400, "invalid_link");
782
813
  if (typeof body.link !== "string" || !parseChannelLink(body.link)) return fail(400, "invalid_link");
814
+ if (Object.hasOwn(body, "sessionId") && !validAgentSessionId(body.sessionId)) return fail(400, "invalid_link");
815
+ if (Object.hasOwn(body, "rejoinSecret") && !validAgentRejoinSecret(body.rejoinSecret)) return fail(400, "invalid_link");
783
816
  const harness = body.harness;
784
- if (harness !== "claude" && harness !== "codex") return fail(400, "invalid_harness");
817
+ if (!HARNESSES.includes(harness)) return fail(400, "invalid_harness");
785
818
  const url = new URL(body.link);
786
819
  if (url.origin !== ctx.origin && url.origin !== ctx.origin.replace("://127.0.0.1:", "://localhost:")) return fail(404, "link_unavailable");
787
820
  const token = url.pathname.slice("/join/".length);
@@ -792,22 +825,32 @@ function agentJoinRoutes() {
792
825
  const link = await ctx.store.consumeLink(token);
793
826
  if (!link || !ctx.store.hasChannel(link.roomId)) return fail(404, "link_unavailable");
794
827
  const roomId = link.roomId;
828
+ const sessionKey = body.sessionId === void 0 || body.rejoinSecret === void 0 ? void 0 : sha256hex(JSON.stringify([harness, body.sessionId, body.rejoinSecret]));
829
+ const previous = sessionKey ? ctx.store.memberForSession(roomId, sessionKey) : void 0;
795
830
  const taken = new Set(ctx.store.members(roomId).map((member) => member.displayName.toLowerCase()));
796
831
  let n = 1;
797
832
  while (taken.has(defaultAgentName(username, harness, n).toLowerCase())) n++;
798
833
  const checked = checkName(defaultAgentName(username, harness, n), "agent");
799
834
  if (!checked.ok) return fail(503, "unavailable");
800
- let userId = newLocalAgentUserId(ctx.random(4));
801
- for (let attempt = 0; attempt < 5 && ctx.store.channelOfMember(userId) !== void 0; attempt++) userId = newLocalAgentUserId(ctx.random(4));
802
- if (ctx.store.channelOfMember(userId) !== void 0) return fail(503, "unavailable");
835
+ let userId = previous?.userId ?? newLocalAgentUserId(ctx.random(4));
836
+ for (let attempt = 0; attempt < 5 && !previous && ctx.store.channelOfMember(userId) !== void 0; attempt++) userId = newLocalAgentUserId(ctx.random(4));
837
+ if (!previous && ctx.store.channelOfMember(userId) !== void 0) return fail(503, "unavailable");
803
838
  const deviceId = "KH_LOCAL_" + userId.slice("@agent-".length, -":local".length);
804
839
  const accessToken = b64(ctx.random(LOCAL_TOKEN_BYTES));
805
840
  await ctx.store.append(roomId, {
806
841
  type: "m.room.member",
807
842
  sender: LOCAL_OWNER_USER_ID,
808
- content: { user: userId, membership: "invite", displayname: checked.name, kind: "agent", harness, invitedBy: LOCAL_OWNER_USER_ID }
843
+ content: {
844
+ user: userId,
845
+ membership: "invite",
846
+ displayname: previous?.displayName ?? checked.name,
847
+ kind: "agent",
848
+ harness,
849
+ invitedBy: LOCAL_OWNER_USER_ID,
850
+ ...previous ? { "com.khala.listening_mode": previous.listeningMode ?? "sync" } : {}
851
+ }
809
852
  });
810
- await ctx.store.setMemberToken(roomId, userId, sha256hex(accessToken));
853
+ await ctx.store.setMemberToken(roomId, userId, sha256hex(accessToken), sessionKey);
811
854
  const joinId = b64(ctx.random(16)), pollSecret = b64(ctx.random(LOCAL_TOKEN_BYTES));
812
855
  const expiresAt = ctx.now() + LOCAL_LINK_TTL_MS;
813
856
  const credentials = { homeserver: ctx.origin, userId, accessToken, deviceId, roomId, transport: "local" };
@@ -886,6 +929,7 @@ function memberEvents(store, roomId, userId) {
886
929
  let current;
887
930
  let joined;
888
931
  let announced = false;
932
+ let joins = 0;
889
933
  for (; ; ) {
890
934
  const page = store.eventsAfter(roomId, after, 200);
891
935
  for (const event of page) {
@@ -896,13 +940,14 @@ function memberEvents(store, roomId, userId) {
896
940
  } else if (current?.content["membership"] !== "join") {
897
941
  joined = event;
898
942
  announced = false;
943
+ joins++;
899
944
  }
900
945
  current = event;
901
946
  } else if (joined && event.type === "com.khala.event.v1" && event.sender === LOCAL_OWNER_USER_ID && event.txnId === announcementTxn(joined)) {
902
947
  announced = true;
903
948
  }
904
949
  }
905
- if (page.length < 200) return { current, joined, announced };
950
+ if (page.length < 200) return { current, joined, announced, joins };
906
951
  after = page.at(-1).seq;
907
952
  }
908
953
  }
@@ -951,14 +996,18 @@ function roomRoutes() {
951
996
  let event;
952
997
  let joined;
953
998
  let announced = false;
999
+ let rejoin = false;
954
1000
  if (caller.member.membership === "join") {
955
- ({ current: event, joined, announced } = memberEvents(ctx.store, caller.roomId, caller.userId));
1001
+ let joins;
1002
+ ({ current: event, joined, announced, joins } = memberEvents(ctx.store, caller.roomId, caller.userId));
1003
+ rejoin = joins > 1;
956
1004
  } else {
1005
+ rejoin = memberEvents(ctx.store, caller.roomId, caller.userId).joins > 0;
957
1006
  event = await ctx.store.append(caller.roomId, { type: "m.room.member", sender: caller.userId, content: memberContent(caller.member) });
958
1007
  joined = event;
959
1008
  }
960
1009
  if (caller.kind === "agent" && joined && !announced) {
961
- const encoded = encodeChannelEvent({ kind: "member", summary: `${joined.content["displayname"]} joined`, status: "info", source: { system: "khala-local" } });
1010
+ const encoded = encodeChannelEvent({ kind: "member", summary: `${joined.content["displayname"]} ${rejoin ? "rejoined" : "joined"}`, status: "info", source: { system: "khala-local" } });
962
1011
  if (encoded.ok) await ctx.store.append(caller.roomId, {
963
1012
  type: "com.khala.event.v1",
964
1013
  sender: LOCAL_OWNER_USER_ID,
@@ -984,7 +1033,12 @@ function roomRoutes() {
984
1033
  if ("status" in current) return current;
985
1034
  events = ctx.store.eventsAfter(caller.roomId, after, 200);
986
1035
  }
987
- return { status: 200, json: { events, next: events.at(-1)?.seq ?? after } };
1036
+ const delivered = req.query.get("prev") === "1" ? events : events.map((event) => {
1037
+ const legacy = { ...event };
1038
+ delete legacy.previousContent;
1039
+ return legacy;
1040
+ });
1041
+ return { status: 200, json: { events: delivered, next: events.at(-1)?.seq ?? after } };
988
1042
  }),
989
1043
  route("GET", /^\/api\/local\/rooms\/([^/]+)\/messages$/u, "joined", async (req, _params, ctx, caller) => {
990
1044
  const before = req.query.get("before");
@@ -1231,14 +1285,14 @@ async function cascade(store, previous, next) {
1231
1285
  const members = store.members(roomId);
1232
1286
  const taken = new Set(members.map((m) => m.displayName.toLowerCase()));
1233
1287
  const owner2 = members.find((m) => m.userId === LOCAL_OWNER_USER_ID);
1234
- if (owner2 && owner2.displayName !== next) {
1288
+ if (owner2 && previous !== next) {
1235
1289
  try {
1236
1290
  await store.append(roomId, {
1237
1291
  type: "m.room.member",
1238
1292
  sender: LOCAL_OWNER_USER_ID,
1239
1293
  content: { user: LOCAL_OWNER_USER_ID, membership: owner2.membership, displayname: next, kind: "human" }
1240
1294
  });
1241
- taken.delete(owner2.displayName.toLowerCase());
1295
+ taken.delete(previous.toLowerCase());
1242
1296
  taken.add(next.toLowerCase());
1243
1297
  } catch {
1244
1298
  }