khala-cli 0.4.4 → 0.4.6

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-OS2JLDL7.mjs → chunk-2U4E7AM7.mjs} +22 -5
  2. package/dist/{chunk-GE3RXO2X.mjs → chunk-2XTYRBYX.mjs} +10 -4
  3. package/dist/{chunk-6OADQWV7.mjs → chunk-4X35OUAN.mjs} +100 -39
  4. package/dist/{chunk-ZKDDKUUA.mjs → chunk-55KBLEX5.mjs} +1 -1
  5. package/dist/{chunk-AK74S7EN.mjs → chunk-6E3UCO22.mjs} +32 -11
  6. package/dist/chunk-AWBKVTLV.mjs +238 -0
  7. package/dist/{chunk-FA6Z65DW.mjs → chunk-B2MOLCJM.mjs} +9 -5
  8. package/dist/{chunk-DC3OX6F4.mjs → chunk-CWHGNVZV.mjs} +0 -18
  9. package/dist/chunk-DPW43YNC.mjs +36 -0
  10. package/dist/{chunk-PJ3E3IVH.mjs → chunk-FH2CZX5T.mjs} +14 -12
  11. package/dist/{chunk-LHAMBQK7.mjs → chunk-IULQYQJZ.mjs} +3 -3
  12. package/dist/{chunk-FUU775QM.mjs → chunk-JG5J3SHW.mjs} +14 -1
  13. package/dist/{chunk-MZS7XFMC.mjs → chunk-OECJ45NM.mjs} +2 -2
  14. package/dist/{chunk-XEYKJMZL.mjs → chunk-OW3DSTEZ.mjs} +57 -4
  15. package/dist/{chunk-ZRFXZK72.mjs → chunk-PFDUXY3N.mjs} +1 -1
  16. package/dist/{chunk-MU4FUENP.mjs → chunk-PY2ZNVNY.mjs} +20 -5
  17. package/dist/{chunk-7CVU5OZ7.mjs → chunk-RRBIVI77.mjs} +1 -1
  18. package/dist/{chunk-LN3G2LJV.mjs → chunk-SZIEG3RS.mjs} +6 -5
  19. package/dist/chunk-WHONG7UF.mjs +267 -0
  20. package/dist/chunk-XUJIJ4KB.mjs +216 -0
  21. package/dist/{chunk-LQ6KNUNN.mjs → chunk-Z4EDYB6Y.mjs} +465 -349
  22. package/dist/chunk-ZHMGB3PP.mjs +82 -0
  23. package/dist/khala.mjs +10 -10
  24. package/dist/web/assets/local-K8EcTVxW.js +9 -0
  25. package/dist/web/assets/local-R2y0Gl4T.css +1 -0
  26. package/dist/web/index.html +2 -2
  27. package/package.json +1 -1
  28. package/dist/chunk-ENPBK5W2.mjs +0 -187
  29. package/dist/chunk-N5VYKQMR.mjs +0 -108
  30. package/dist/chunk-VUE2DDYY.mjs +0 -34
  31. package/dist/web/assets/local-Bk8hMIT-.js +0 -9
  32. package/dist/web/assets/local-vdvavyq0.css +0 -1
@@ -0,0 +1,267 @@
1
+ import { createRequire as __khalaCreateRequire } from 'node:module'; const require = __khalaCreateRequire(import.meta.url);
2
+ import {
3
+ CURSOR_DEFAULT_SESSION,
4
+ CURSOR_WORKSPACE_ENV,
5
+ cursorSessionId,
6
+ readActivity,
7
+ readListeningMode
8
+ } from "./chunk-ZHMGB3PP.mjs";
9
+ import {
10
+ listChannels,
11
+ readCursor,
12
+ unreadCount
13
+ } from "./chunk-XUJIJ4KB.mjs";
14
+ import {
15
+ HARNESS_REGISTRY
16
+ } from "./chunk-DPW43YNC.mjs";
17
+ import {
18
+ SESSION_ID_PATTERN,
19
+ readJson
20
+ } from "./chunk-OW3DSTEZ.mjs";
21
+
22
+ // src/harness/claude.ts
23
+ var claude = {
24
+ id: "claude",
25
+ sessionSources: [(_meta, env) => env.CLAUDE_CODE_SESSION_ID],
26
+ codec: void 0,
27
+ rejoinable: () => true,
28
+ watcherStatus: true
29
+ };
30
+
31
+ // src/wake/codex.ts
32
+ import path from "node:path";
33
+
34
+ // src/wake/idle-wake.ts
35
+ var CODEX_IDLE_WAKE_NOTICE = "Khala: channel messages are waiting. Continue.";
36
+ function codexIdleWakeArgv(sessionId) {
37
+ return ["queue", "--thread", sessionId, "--message", CODEX_IDLE_WAKE_NOTICE];
38
+ }
39
+
40
+ // src/wake/idle-wake-process.ts
41
+ import { spawn as nodeSpawn } from "node:child_process";
42
+ var CODEX_QUEUE_ENV_ALLOWLIST = [
43
+ "PATH",
44
+ "HOME",
45
+ "USER",
46
+ "LOGNAME",
47
+ "SHELL",
48
+ "LANG",
49
+ "LC_ALL",
50
+ "TMPDIR",
51
+ "CODEX_HOME",
52
+ "XDG_CONFIG_HOME",
53
+ "XDG_DATA_HOME",
54
+ "XDG_STATE_HOME",
55
+ "XDG_RUNTIME_DIR"
56
+ ];
57
+ function scrubbedQueueEnv(source) {
58
+ const env = {};
59
+ for (const name of CODEX_QUEUE_ENV_ALLOWLIST) {
60
+ const value = source[name];
61
+ if (value !== void 0) env[name] = value;
62
+ }
63
+ return env;
64
+ }
65
+ function createCodexQueueProcessPort(deps) {
66
+ const spawn = deps.spawn ?? ((command, argv, options) => nodeSpawn(command, [...argv], options));
67
+ const timeoutMs = deps.timeoutMs ?? 1e4;
68
+ return {
69
+ run(argv, signal) {
70
+ return new Promise((resolve) => {
71
+ if (signal.aborted) return resolve({ status: "not_started" });
72
+ let child;
73
+ try {
74
+ child = spawn(deps.command, argv, { shell: false, stdio: "ignore", env: scrubbedQueueEnv(deps.env) });
75
+ } catch {
76
+ return resolve({ status: "not_started" });
77
+ }
78
+ let timedOut = false;
79
+ let stopped = false;
80
+ let force = null;
81
+ const stop = () => {
82
+ if (stopped) return;
83
+ stopped = true;
84
+ child.kill("SIGTERM");
85
+ force = setTimeout(() => {
86
+ child.kill("SIGKILL");
87
+ }, 500);
88
+ };
89
+ const timer = setTimeout(() => {
90
+ timedOut = true;
91
+ stop();
92
+ }, timeoutMs);
93
+ signal.addEventListener("abort", stop, { once: true });
94
+ const settle = (outcome) => {
95
+ clearTimeout(timer);
96
+ if (force !== null) clearTimeout(force);
97
+ signal.removeEventListener("abort", stop);
98
+ resolve(outcome);
99
+ };
100
+ child.on("error", () => settle({ status: "not_started" }));
101
+ child.on("close", (code) => settle(timedOut ? { status: "lost", cause: "timeout" } : code === 0 ? { status: "queued" } : { status: "exited", code: code ?? -1 }));
102
+ });
103
+ }
104
+ };
105
+ }
106
+
107
+ // src/wake/codex.ts
108
+ function createCodexWaker(deps) {
109
+ if (!SESSION_ID_PATTERN.test(deps.threadId)) throw new TypeError("invalid_thread_id");
110
+ const port = deps.port ?? createCodexQueueProcessPort({ command: "codex", env: process.env });
111
+ const now = deps.now ?? Date.now;
112
+ const stderr = deps.stderr ?? ((line) => {
113
+ process.stderr.write(line);
114
+ });
115
+ const controller = new AbortController();
116
+ let pending;
117
+ const wakesAtCount = /* @__PURE__ */ new Map();
118
+ const identities = /* @__PURE__ */ new Map();
119
+ let stopped = false;
120
+ let again = false;
121
+ let inFlight;
122
+ const warn = (warning, status) => {
123
+ try {
124
+ stderr(JSON.stringify({ ok: false, warning, ...status ? { status } : {} }) + "\n");
125
+ } catch {
126
+ }
127
+ };
128
+ const evaluate = async () => {
129
+ try {
130
+ const channels = await listChannels(deps.files);
131
+ const eligible = [];
132
+ const currentIdentities = /* @__PURE__ */ new Map();
133
+ for (const channel of channels) {
134
+ const session = await readJson(channel.files.session);
135
+ const metadata = channel.legacy ? null : await readJson(path.join(channel.files.dir, "channel.json"));
136
+ currentIdentities.set(channel.key, JSON.stringify([session?.roomId ?? channel.roomId, session?.userId, metadata?.joinedAt]));
137
+ if (await readListeningMode(channel.files) === "async") continue;
138
+ eligible.push({
139
+ channel,
140
+ counts: await unreadCount(channel.files.dir),
141
+ cursor: await readCursor(channel.files)
142
+ });
143
+ }
144
+ const keys = new Set(currentIdentities.keys());
145
+ const changed = /* @__PURE__ */ new Set();
146
+ for (const [key, identity] of currentIdentities) {
147
+ if (identities.get(key) !== identity) changed.add(key);
148
+ }
149
+ for (const key of identities.keys()) if (!keys.has(key)) changed.add(key);
150
+ if (changed.size) {
151
+ for (const [key, wakes2] of [...wakesAtCount]) {
152
+ const pairs2 = JSON.parse(key);
153
+ if (!pairs2.some(([id]) => changed.has(id))) continue;
154
+ wakesAtCount.delete(key);
155
+ const remainingPairs = pairs2.filter(([id]) => !changed.has(id));
156
+ if (!remainingPairs.length) continue;
157
+ const remaining = JSON.stringify(remainingPairs);
158
+ wakesAtCount.set(remaining, Math.max(wakes2, wakesAtCount.get(remaining) ?? 0));
159
+ }
160
+ }
161
+ for (const key of changed) identities.delete(key);
162
+ for (const [key, identity] of currentIdentities) identities.set(key, identity);
163
+ const pairs = eligible.map(({ channel, cursor: cursor2 }) => [channel.key, cursor2.deliveredCount]).sort(([a], [b]) => a.localeCompare(b));
164
+ const budgetKey = JSON.stringify(pairs);
165
+ if (changed.size) {
166
+ const inheritedKey = JSON.stringify(pairs.filter(([key]) => !changed.has(key)));
167
+ const newUnread = eligible.some((item) => changed.has(item.channel.key) && item.counts.messages > 0);
168
+ wakesAtCount.set(budgetKey, newUnread ? 0 : wakesAtCount.get(inheritedKey) ?? 0);
169
+ if (newUnread || !eligible.some((item) => !changed.has(item.channel.key) && item.counts.messages > 0)) pending = void 0;
170
+ }
171
+ if (!eligible.some((item) => item.counts.messages > 0)) {
172
+ pending = void 0;
173
+ return;
174
+ }
175
+ const activity = await readActivity(deps.files);
176
+ if (pending && Date.parse(activity.updatedAt) > pending.at) pending = void 0;
177
+ const at = now();
178
+ if (pending && at - pending.at < (deps.retryAfterMs ?? 6e4)) return;
179
+ if (stopped || activity.state !== "idle") return;
180
+ const wakes = wakesAtCount.get(budgetKey) ?? 0;
181
+ if (wakes >= 2) return;
182
+ for (const { channel } of eligible) {
183
+ if (await readListeningMode(channel.files) === "async") {
184
+ pending = void 0;
185
+ return;
186
+ }
187
+ }
188
+ if (stopped) return;
189
+ pending = { at };
190
+ wakesAtCount.set(budgetKey, wakes + 1);
191
+ const outcome = await port.run(codexIdleWakeArgv(deps.threadId), controller.signal);
192
+ if (outcome.status !== "queued") warn("codex_queue_failed", outcome.status);
193
+ } catch {
194
+ warn("codex_waker_error");
195
+ }
196
+ };
197
+ const notify = () => {
198
+ if (stopped) return;
199
+ if (inFlight) {
200
+ again = true;
201
+ return;
202
+ }
203
+ inFlight = (async () => {
204
+ do {
205
+ again = false;
206
+ await evaluate();
207
+ } while (again && !stopped);
208
+ })().finally(() => {
209
+ inFlight = void 0;
210
+ if (again && !stopped) notify();
211
+ });
212
+ };
213
+ const timer = setInterval(notify, deps.pollMs ?? 1e3);
214
+ timer.unref();
215
+ return {
216
+ notify,
217
+ async stop() {
218
+ stopped = true;
219
+ again = false;
220
+ clearInterval(timer);
221
+ controller.abort();
222
+ await inFlight;
223
+ }
224
+ };
225
+ }
226
+
227
+ // src/harness/codex.ts
228
+ var install = async (flags, deps) => (await import("./chunk-6E3UCO22.mjs")).runCodexInstall(flags, deps);
229
+ var codex = {
230
+ id: "codex",
231
+ // A string metadata id wins even when empty or invalid; validation happens after selection.
232
+ sessionSources: [(meta, env) => typeof meta?.threadId === "string" ? meta.threadId : env.CODEX_THREAD_ID],
233
+ codec: void 0,
234
+ install,
235
+ uninstall: (flags, deps) => install([...flags, "--uninstall"], deps),
236
+ waker: createCodexWaker,
237
+ rejoinable: () => true
238
+ };
239
+
240
+ // src/harness/cursor.ts
241
+ var install2 = async (flags, deps) => (await import("./chunk-6E3UCO22.mjs")).runCursorInstall(flags, deps);
242
+ var cursor = {
243
+ id: "cursor",
244
+ sessionSources: [(_meta, env) => cursorSessionId(env[CURSOR_WORKSPACE_ENV])],
245
+ codec: void 0,
246
+ install: install2,
247
+ uninstall: (flags, deps) => install2([...flags, "--uninstall"], deps),
248
+ rejoinable: (source) => source !== CURSOR_DEFAULT_SESSION
249
+ };
250
+
251
+ // src/harness/index.ts
252
+ function createAdapterRegistry(adapters) {
253
+ const byId = /* @__PURE__ */ new Map();
254
+ for (const adapter of adapters) {
255
+ if (byId.has(adapter.id)) throw new Error(`duplicate_adapter: ${adapter.id}`);
256
+ if (!HARNESS_REGISTRY.some((row) => row.id === adapter.id)) throw new Error(`unregistered_adapter: ${adapter.id}`);
257
+ byId.set(adapter.id, adapter);
258
+ }
259
+ return Object.freeze({ resolve: (id) => byId.get(id) });
260
+ }
261
+ var ADAPTERS = Object.freeze([claude, codex, cursor]);
262
+ var registry = createAdapterRegistry(ADAPTERS);
263
+ var adapterFor = registry.resolve;
264
+
265
+ export {
266
+ adapterFor
267
+ };
@@ -0,0 +1,216 @@
1
+ import { createRequire as __khalaCreateRequire } from 'node:module'; const require = __khalaCreateRequire(import.meta.url);
2
+ import {
3
+ StateError,
4
+ channelFiles,
5
+ channelsDir,
6
+ ensureStateDir,
7
+ filesForDir,
8
+ readJson,
9
+ readStatus,
10
+ removeStateFile,
11
+ stateKey,
12
+ writeJsonAtomic,
13
+ writeStateFile
14
+ } from "./chunk-OW3DSTEZ.mjs";
15
+
16
+ // src/inbox.ts
17
+ import * as fs from "node:fs/promises";
18
+ async function readEntries(files) {
19
+ let content;
20
+ try {
21
+ content = await fs.readFile(files.inbox, "utf8");
22
+ } catch (error) {
23
+ if (error.code === "ENOENT") return [];
24
+ throw new StateError("storage_failed");
25
+ }
26
+ const entries = [];
27
+ for (const line of content.split("\n").slice(0, -1)) {
28
+ try {
29
+ const entry = JSON.parse(line);
30
+ if (entry && ["eventId", "roomId", "ts", "sender", "senderLabel", "body"].every((key) => typeof entry[key] === "string") && ["human", "agent", "unknown"].includes(entry.senderKind) && ["message", "event"].includes(entry.kind)) entries.push(entry);
31
+ } catch {
32
+ }
33
+ }
34
+ return entries;
35
+ }
36
+ async function appendEntries(files, entries) {
37
+ const seen = new Set((await readEntries(files)).map((entry) => entry.eventId));
38
+ const fresh = entries.filter((entry) => {
39
+ if (seen.has(entry.eventId)) return false;
40
+ seen.add(entry.eventId);
41
+ return true;
42
+ });
43
+ if (fresh.length) {
44
+ try {
45
+ await fs.appendFile(files.inbox, fresh.map((entry) => JSON.stringify(entry) + "\n").join(""), { mode: 384, flag: "a" });
46
+ } catch {
47
+ throw new StateError("storage_failed");
48
+ }
49
+ }
50
+ return fresh;
51
+ }
52
+ async function readCursor(files) {
53
+ const cursor = await readJson(files.cursor);
54
+ return cursor && (cursor.lastDeliveredEventId === null || typeof cursor.lastDeliveredEventId === "string") && Number.isSafeInteger(cursor.deliveredCount) && cursor.deliveredCount >= 0 ? cursor : { lastDeliveredEventId: null, deliveredCount: 0 };
55
+ }
56
+ async function unread(files) {
57
+ const cursor = await readCursor(files);
58
+ const entries = await readEntries(files);
59
+ return { entries: entries.slice(cursor.deliveredCount), cursor };
60
+ }
61
+ async function unreadCounts(files) {
62
+ const { entries } = await unread(files);
63
+ return { total: entries.length, messages: entries.filter((entry) => entry.kind === "message").length };
64
+ }
65
+ async function advanceCursor(files, expected, delivered) {
66
+ const current = await readCursor(files);
67
+ if (current.deliveredCount !== expected.deliveredCount || current.lastDeliveredEventId !== expected.lastDeliveredEventId) return "conflict";
68
+ if (!delivered.length) return expected;
69
+ const entries = await readEntries(files);
70
+ const slice = entries.slice(expected.deliveredCount, expected.deliveredCount + delivered.length);
71
+ if (slice.length !== delivered.length || slice.some((entry, index) => entry.eventId !== delivered[index]?.eventId)) return "conflict";
72
+ const next = { lastDeliveredEventId: delivered[delivered.length - 1].eventId, deliveredCount: expected.deliveredCount + delivered.length };
73
+ await writeJsonAtomic(files.cursor, next);
74
+ return next;
75
+ }
76
+ async function appendInbox(dir, entry) {
77
+ return (await appendEntries(filesForDir(dir), [entry])).length === 1;
78
+ }
79
+ function unreadCount(dir) {
80
+ return unreadCounts(filesForDir(dir));
81
+ }
82
+
83
+ // src/channels.ts
84
+ import * as fs2 from "node:fs/promises";
85
+ import path from "node:path";
86
+ function channelKey(roomId2) {
87
+ return stateKey(roomId2);
88
+ }
89
+ async function exists(file) {
90
+ try {
91
+ await fs2.lstat(file);
92
+ return true;
93
+ } catch (error) {
94
+ if (error.code === "ENOENT") return false;
95
+ throw new StateError("storage_failed");
96
+ }
97
+ }
98
+ function roomId(value) {
99
+ if (value && typeof value === "object" && "roomId" in value && typeof value.roomId === "string" && value.roomId.length) return value.roomId;
100
+ return void 0;
101
+ }
102
+ async function legacyRoomId(files) {
103
+ const saved = roomId(await readJson(files.session));
104
+ if (saved) return saved;
105
+ let content;
106
+ try {
107
+ content = await fs2.readFile(files.inbox, "utf8");
108
+ } catch (error) {
109
+ if (error.code === "ENOENT") return void 0;
110
+ throw new StateError("storage_failed");
111
+ }
112
+ try {
113
+ return roomId(JSON.parse(content.split("\n")[0]));
114
+ } catch {
115
+ return void 0;
116
+ }
117
+ }
118
+ async function listChannels(files) {
119
+ await ensureStateDir(files.dir);
120
+ const channels = [];
121
+ const dir = channelsDir(files);
122
+ if (await exists(dir)) {
123
+ await ensureStateDir(dir);
124
+ for (const key of await fs2.readdir(dir)) {
125
+ if (!/^[a-f0-9]{24}$/.test(key)) continue;
126
+ const nested = filesForDir(path.join(dir, key));
127
+ await ensureStateDir(nested.dir);
128
+ const metadata = await readJson(path.join(nested.dir, "channel.json"));
129
+ const id = roomId(metadata);
130
+ if (!id || channelKey(id) !== key) continue;
131
+ channels.push({ key, roomId: id, ...typeof metadata?.channelName === "string" ? { channelName: metadata.channelName } : {}, files: nested, legacy: false });
132
+ }
133
+ }
134
+ if (await exists(files.inbox)) {
135
+ const id = await legacyRoomId(files);
136
+ if (id) {
137
+ const status = await readStatus(files);
138
+ channels.push({ key: channelKey(id), roomId: id, ...typeof status?.channelName === "string" ? { channelName: status.channelName } : {}, files, legacy: true });
139
+ }
140
+ }
141
+ return channels.sort((a, b) => (a.channelName ?? "").localeCompare(b.channelName ?? "") || a.roomId.localeCompare(b.roomId));
142
+ }
143
+ async function migrateLegacy(files) {
144
+ await ensureStateDir(files.dir);
145
+ const names = ["cursor.json", "mode.json", "inbox.jsonl"];
146
+ const present = await Promise.all(names.map((name) => exists(path.join(files.dir, name))));
147
+ if (!present.some(Boolean)) return "none";
148
+ const id = await legacyRoomId(files);
149
+ if (!id) {
150
+ for (const name of names) await removeStateFile(files.dir, name);
151
+ return "discarded";
152
+ }
153
+ const nested = channelFiles(files, id);
154
+ await ensureStateDir(nested.dir);
155
+ const destinationInbox = await exists(nested.inbox);
156
+ if (!destinationInbox && present[0] && await exists(nested.cursor)) {
157
+ const [legacyCursor, channelCursor] = await Promise.all([readCursor(files), readCursor(nested)]);
158
+ if (legacyCursor.deliveredCount !== channelCursor.deliveredCount || legacyCursor.lastDeliveredEventId !== channelCursor.lastDeliveredEventId) {
159
+ const safe = legacyCursor.deliveredCount < channelCursor.deliveredCount ? legacyCursor : channelCursor;
160
+ await writeStateFile(nested.dir, "cursor.json", safe);
161
+ console.warn(`Khala cursor migration: conflicting cursors for ${id}; using earlier position ${safe.deliveredCount} to avoid skipping messages (legacy=${legacyCursor.deliveredCount}, channel=${channelCursor.deliveredCount}).`);
162
+ }
163
+ }
164
+ const status = await readStatus(files);
165
+ const metadata = await readJson(path.join(nested.dir, "channel.json"));
166
+ if (!metadata || roomId(metadata) !== id) {
167
+ await writeStateFile(nested.dir, "channel.json", {
168
+ roomId: id,
169
+ ...typeof status?.channelName === "string" ? { channelName: status.channelName } : {},
170
+ joinedAt: (/* @__PURE__ */ new Date()).toISOString()
171
+ });
172
+ }
173
+ if (destinationInbox) {
174
+ if (present[2]) await appendEntries(nested, (await unread(files)).entries);
175
+ for (const name of ["inbox.jsonl", "cursor.json", "mode.json"]) await removeStateFile(files.dir, name);
176
+ return "moved";
177
+ }
178
+ for (const name of names) {
179
+ if (await exists(path.join(nested.dir, name))) {
180
+ await removeStateFile(files.dir, name);
181
+ continue;
182
+ }
183
+ try {
184
+ await fs2.rename(path.join(files.dir, name), path.join(nested.dir, name));
185
+ } catch (error) {
186
+ if (error.code !== "ENOENT") throw new StateError("storage_failed");
187
+ }
188
+ }
189
+ return "moved";
190
+ }
191
+ function resolveChannel(list, ref) {
192
+ const channels = list.map((item) => ({ channel: item.channelName ?? item.roomId, roomId: item.roomId }));
193
+ if (ref === void 0) {
194
+ if (list.length === 1) return { ok: true, channel: list[0] };
195
+ return { ok: false, code: list.length ? "channel_required" : "channel_unknown", channels };
196
+ }
197
+ const exact = list.find((item) => item.roomId === ref);
198
+ if (exact) return { ok: true, channel: exact };
199
+ const normalized = ref.replace(/^#/, "").toLowerCase();
200
+ const matches = list.filter((item) => item.channelName?.replace(/^#/, "").toLowerCase() === normalized);
201
+ if (matches.length === 1) return { ok: true, channel: matches[0] };
202
+ return { ok: false, code: matches.length ? "channel_ambiguous" : "channel_unknown", channels };
203
+ }
204
+
205
+ export {
206
+ readEntries,
207
+ readCursor,
208
+ unread,
209
+ advanceCursor,
210
+ appendInbox,
211
+ unreadCount,
212
+ channelKey,
213
+ listChannels,
214
+ migrateLegacy,
215
+ resolveChannel
216
+ };