khala-cli 0.4.6 → 0.4.8

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 (35) hide show
  1. package/README.md +18 -1
  2. package/dist/{chunk-2U4E7AM7.mjs → chunk-2DHIAFEV.mjs} +294 -101
  3. package/dist/{chunk-CWHGNVZV.mjs → chunk-2VCAW4GH.mjs} +1 -1
  4. package/dist/{chunk-P5CP32X2.mjs → chunk-54XFVSEC.mjs} +1 -1
  5. package/dist/chunk-5BQTYU66.mjs +886 -0
  6. package/dist/{chunk-RRBIVI77.mjs → chunk-5LIE5GUU.mjs} +1 -1
  7. package/dist/{chunk-6E3UCO22.mjs → chunk-5PUQT3YS.mjs} +200 -20
  8. package/dist/{chunk-55KBLEX5.mjs → chunk-7HBRFY3Z.mjs} +1 -1
  9. package/dist/{chunk-PFDUXY3N.mjs → chunk-APMWDF6B.mjs} +1 -1
  10. package/dist/{chunk-PY2ZNVNY.mjs → chunk-BKLW2SPG.mjs} +55 -8
  11. package/dist/{chunk-OW3DSTEZ.mjs → chunk-CBPLCCRZ.mjs} +5 -5
  12. package/dist/{chunk-JG5J3SHW.mjs → chunk-CJTCU755.mjs} +4 -4
  13. package/dist/{chunk-AWBKVTLV.mjs → chunk-FH34B3I3.mjs} +14 -17
  14. package/dist/chunk-FV32AQTC.mjs +40 -0
  15. package/dist/{chunk-FH2CZX5T.mjs → chunk-H3K4GA2P.mjs} +83 -36
  16. package/dist/{chunk-AUJEUKVM.mjs → chunk-IQJQ4RF5.mjs} +1 -1
  17. package/dist/{chunk-SN3STAO2.mjs → chunk-NKX3SLS7.mjs} +40 -3
  18. package/dist/{chunk-Z4EDYB6Y.mjs → chunk-RNDUFTBF.mjs} +97 -57
  19. package/dist/{chunk-OECJ45NM.mjs → chunk-VAI7IUI4.mjs} +24 -8
  20. package/dist/{chunk-XUJIJ4KB.mjs → chunk-VKQ3KUYO.mjs} +16 -2
  21. package/dist/{chunk-4X35OUAN.mjs → chunk-VUSV2ADL.mjs} +92 -97
  22. package/dist/{chunk-2XTYRBYX.mjs → chunk-W6R4UJUQ.mjs} +12 -5
  23. package/dist/{chunk-B2MOLCJM.mjs → chunk-Z4JF2M5C.mjs} +7 -9
  24. package/dist/{chunk-SZIEG3RS.mjs → chunk-ZJ4W7P33.mjs} +12 -14
  25. package/dist/khala.mjs +11 -11
  26. package/dist/web/assets/local-BQl2KrE1.js +9 -0
  27. package/dist/web/assets/local-OwDq47O4.css +1 -0
  28. package/dist/web/index.html +2 -2
  29. package/package.json +1 -1
  30. package/dist/chunk-DPW43YNC.mjs +0 -36
  31. package/dist/chunk-IULQYQJZ.mjs +0 -144
  32. package/dist/chunk-WHONG7UF.mjs +0 -267
  33. package/dist/chunk-ZHMGB3PP.mjs +0 -82
  34. package/dist/web/assets/local-K8EcTVxW.js +0 -9
  35. package/dist/web/assets/local-R2y0Gl4T.css +0 -1
@@ -0,0 +1,886 @@
1
+ import { createRequire as __khalaCreateRequire } from 'node:module'; const require = __khalaCreateRequire(import.meta.url);
2
+ import {
3
+ advanceCursor,
4
+ listChannels,
5
+ readActivity,
6
+ readCursor,
7
+ unread
8
+ } from "./chunk-VKQ3KUYO.mjs";
9
+ import {
10
+ SESSION_ID_PATTERN,
11
+ StateError,
12
+ ensureStateDir,
13
+ readJson,
14
+ stateRoot,
15
+ writeJsonAtomic
16
+ } from "./chunk-CBPLCCRZ.mjs";
17
+ import {
18
+ DEFAULT_LISTENING_MODE,
19
+ decodeListeningMode
20
+ } from "./chunk-IQJQ4RF5.mjs";
21
+ import {
22
+ HARNESS_REGISTRY,
23
+ isHarnessId
24
+ } from "./chunk-NKX3SLS7.mjs";
25
+
26
+ // src/mode.ts
27
+ async function readListeningMode(files) {
28
+ const content = await readJson(files.mode);
29
+ const decoded = decodeListeningMode(typeof content === "object" && content !== null ? content.mode : void 0);
30
+ return decoded.ok ? decoded.value : DEFAULT_LISTENING_MODE;
31
+ }
32
+ async function applyListeningMode(files, next, meta, now = () => /* @__PURE__ */ new Date()) {
33
+ const previous = await readListeningMode(files);
34
+ if (previous === "async" && next !== "async") {
35
+ for (let attempt = 0; attempt < 2; attempt++) {
36
+ const { cursor: cursor2, entries } = await unread(files);
37
+ if (await advanceCursor(files, cursor2, entries) !== "conflict") break;
38
+ if (attempt === 1) throw new StateError("storage_failed");
39
+ }
40
+ }
41
+ await writeJsonAtomic(files.mode, { mode: next, ...meta, updatedAt: now().toISOString() });
42
+ return { previous, next };
43
+ }
44
+
45
+ // src/harness/codecs/claude-style.ts
46
+ var events = { SessionStart: "start", UserPromptSubmit: "prompt", PostToolUse: "tool", Stop: "stop" };
47
+ var names = { prompt: "UserPromptSubmit", tool: "PostToolUse", stop: "Stop" };
48
+ var claudeStyleCodec = {
49
+ promptDeliversWithoutWake: true,
50
+ promptAcceptsContext: true,
51
+ suppressOutputErrors: false,
52
+ parse(stdin) {
53
+ try {
54
+ const input = JSON.parse(stdin);
55
+ if (!input || typeof input.session_id !== "string" || typeof input.hook_event_name !== "string" || !Object.hasOwn(events, input.hook_event_name) || input.stop_hook_active !== void 0 && typeof input.stop_hook_active !== "boolean") return null;
56
+ return {
57
+ sessionId: input.session_id,
58
+ event: events[input.hook_event_name],
59
+ continuation: input.stop_hook_active === true,
60
+ ...typeof input.cwd === "string" ? { workspace: input.cwd } : {},
61
+ ...typeof input.prompt === "string" ? { promptText: input.prompt } : {}
62
+ };
63
+ } catch {
64
+ return null;
65
+ }
66
+ },
67
+ noop: () => "",
68
+ render(kind, frame) {
69
+ return JSON.stringify(kind === "stop" ? { decision: "block", reason: frame } : { hookSpecificOutput: { hookEventName: names[kind], additionalContext: frame } }) + "\n";
70
+ }
71
+ };
72
+
73
+ // src/harness/claude.ts
74
+ var claude = {
75
+ id: "claude",
76
+ sessionSources: [{ kind: "env", resolve: (_meta, env) => env.CLAUDE_CODE_SESSION_ID, rejoinable: () => true }],
77
+ codec: claudeStyleCodec,
78
+ restoreAtStartup: true,
79
+ watcherStatus: true
80
+ };
81
+
82
+ // src/wake/shared/rules.ts
83
+ import { randomBytes } from "node:crypto";
84
+ var CODEX_IDLE_WAKE_NOTICE = "Khala: channel messages are waiting. Continue.";
85
+ function newNonce() {
86
+ return randomBytes(4).toString("hex");
87
+ }
88
+ function wakeLine(nonce) {
89
+ if (!/^[a-f0-9]{8}$/.test(nonce)) throw new Error("invalid_wake_nonce");
90
+ return `${CODEX_IDLE_WAKE_NOTICE} (k-${nonce})`;
91
+ }
92
+
93
+ // src/wake/idle-wake.ts
94
+ function codexIdleWakeArgv(sessionId, line) {
95
+ return ["queue", "--thread", sessionId, "--message", line];
96
+ }
97
+
98
+ // src/wake/idle-wake-process.ts
99
+ import { spawn as nodeSpawn } from "node:child_process";
100
+ var CODEX_QUEUE_ENV_ALLOWLIST = [
101
+ "PATH",
102
+ "HOME",
103
+ "USER",
104
+ "LOGNAME",
105
+ "SHELL",
106
+ "LANG",
107
+ "LC_ALL",
108
+ "TMPDIR",
109
+ "CODEX_HOME",
110
+ "XDG_CONFIG_HOME",
111
+ "XDG_DATA_HOME",
112
+ "XDG_STATE_HOME",
113
+ "XDG_RUNTIME_DIR"
114
+ ];
115
+ function scrubbedQueueEnv(source) {
116
+ const env = {};
117
+ for (const name of CODEX_QUEUE_ENV_ALLOWLIST) {
118
+ const value = source[name];
119
+ if (value !== void 0) env[name] = value;
120
+ }
121
+ return env;
122
+ }
123
+ function createCodexQueueProcessPort(deps) {
124
+ const spawn = deps.spawn ?? ((command, argv, options) => nodeSpawn(command, [...argv], options));
125
+ const timeoutMs = deps.timeoutMs ?? 1e4;
126
+ return {
127
+ run(argv, signal) {
128
+ return new Promise((resolve) => {
129
+ if (signal.aborted) return resolve({ status: "not_started" });
130
+ let child;
131
+ try {
132
+ child = spawn(deps.command, argv, { shell: false, stdio: "ignore", env: scrubbedQueueEnv(deps.env) });
133
+ } catch {
134
+ return resolve({ status: "not_started" });
135
+ }
136
+ let timedOut = false;
137
+ let stopped = false;
138
+ let force = null;
139
+ const stop = () => {
140
+ if (stopped) return;
141
+ stopped = true;
142
+ child.kill("SIGTERM");
143
+ force = setTimeout(() => {
144
+ child.kill("SIGKILL");
145
+ }, 500);
146
+ };
147
+ const timer = setTimeout(() => {
148
+ timedOut = true;
149
+ stop();
150
+ }, timeoutMs);
151
+ signal.addEventListener("abort", stop, { once: true });
152
+ const settle = (outcome) => {
153
+ clearTimeout(timer);
154
+ if (force !== null) clearTimeout(force);
155
+ signal.removeEventListener("abort", stop);
156
+ resolve(outcome);
157
+ };
158
+ child.on("error", () => settle({ status: "not_started" }));
159
+ child.on("close", (code) => settle(timedOut ? { status: "lost", cause: "timeout" } : code === 0 ? { status: "queued" } : { status: "exited", code: code ?? -1 }));
160
+ });
161
+ }
162
+ };
163
+ }
164
+
165
+ // src/wake/codex-probe.ts
166
+ import { execFile } from "node:child_process";
167
+ import { access, realpath, stat } from "node:fs/promises";
168
+ import { constants } from "node:fs";
169
+ import path from "node:path";
170
+ import { promisify } from "node:util";
171
+ var execute = promisify(execFile);
172
+ var probes = /* @__PURE__ */ new Map();
173
+ async function probeCodexQueue(env) {
174
+ let command;
175
+ for (const dir of (env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
176
+ try {
177
+ const candidate = path.join(dir, process.platform === "win32" ? "codex.exe" : "codex");
178
+ await access(candidate, constants.X_OK);
179
+ if (!(await stat(candidate)).isFile()) continue;
180
+ command = await realpath(candidate);
181
+ break;
182
+ } catch {
183
+ }
184
+ }
185
+ if (!command) return { available: false, reason: "codex_binary_missing" };
186
+ let mtime;
187
+ try {
188
+ mtime = (await stat(command)).mtimeMs;
189
+ } catch {
190
+ return { available: false, reason: "codex_binary_missing" };
191
+ }
192
+ const cached = probes.get(command);
193
+ if (cached?.mtime === mtime) return cached.result;
194
+ const binary = command;
195
+ const result = (async () => {
196
+ try {
197
+ const { stdout } = await execute(binary, ["queue", "--help"], {
198
+ env: scrubbedQueueEnv(env),
199
+ timeout: 5e3,
200
+ killSignal: "SIGKILL",
201
+ maxBuffer: 64 * 1024,
202
+ windowsHide: true
203
+ });
204
+ return /--thread\b/.test(stdout) && /--message\b/.test(stdout) ? { available: true, command: binary } : { available: false, reason: "codex_queue_unavailable" };
205
+ } catch {
206
+ return { available: false, reason: "codex_queue_unavailable" };
207
+ }
208
+ })();
209
+ probes.set(binary, { mtime, result });
210
+ return result;
211
+ }
212
+
213
+ // src/wake/ladder.ts
214
+ import path4 from "node:path";
215
+
216
+ // src/wake/shared/settings.ts
217
+ import * as fs from "node:fs/promises";
218
+ import path2 from "node:path";
219
+ async function readWakeSettings(root) {
220
+ try {
221
+ const value = JSON.parse(await fs.readFile(path2.join(root, "wake-settings.json"), "utf8"));
222
+ const entries = (input) => {
223
+ if (!input || typeof input !== "object" || Array.isArray(input)) return {};
224
+ return Object.fromEntries(Object.entries(input).filter(([, entry]) => entry && typeof entry === "object" && typeof entry.at === "string"));
225
+ };
226
+ return { consent: entries(value?.consent), off: entries(value?.off) };
227
+ } catch (error) {
228
+ if (error.code === "ENOENT") return { consent: {}, off: {} };
229
+ throw error;
230
+ }
231
+ }
232
+ async function driverAllowed(root, harness, driver, optIn) {
233
+ const settings = await readWakeSettings(root);
234
+ const key = `${harness}/${driver}`;
235
+ return !Object.hasOwn(settings.off, key) && (!optIn || Object.hasOwn(settings.consent, key));
236
+ }
237
+
238
+ // src/wake/shared/nonce.ts
239
+ import * as fs2 from "node:fs/promises";
240
+ import path3 from "node:path";
241
+ import { randomBytes as randomBytes2 } from "node:crypto";
242
+ async function readJson2(file, fallback) {
243
+ try {
244
+ return JSON.parse(await fs2.readFile(file, "utf8"));
245
+ } catch (error) {
246
+ if (error.code === "ENOENT") return fallback;
247
+ throw error;
248
+ }
249
+ }
250
+ async function atomic(file, value) {
251
+ const temp = `${file}.${randomBytes2(8).toString("hex")}.tmp`;
252
+ try {
253
+ await fs2.writeFile(temp, JSON.stringify(value), { mode: 384 });
254
+ await fs2.rename(temp, file);
255
+ } finally {
256
+ await fs2.rm(temp, { force: true });
257
+ }
258
+ }
259
+ async function clearAbandonedLock(lock) {
260
+ let entries;
261
+ try {
262
+ entries = await fs2.readdir(lock);
263
+ } catch (error) {
264
+ if (error.code === "ENOENT") return;
265
+ throw error;
266
+ }
267
+ for (const entry of entries) {
268
+ const owner = /^owner-(\d+)-[a-f0-9]{16}$/.exec(entry);
269
+ if (!owner) continue;
270
+ try {
271
+ process.kill(Number(owner[1]), 0);
272
+ } catch (error) {
273
+ if (error.code !== "ESRCH") continue;
274
+ await fs2.rm(path3.join(lock, entry), { force: true });
275
+ }
276
+ }
277
+ try {
278
+ await fs2.rmdir(lock);
279
+ } catch (error) {
280
+ if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code ?? "")) throw error;
281
+ }
282
+ }
283
+ async function locked(dir, work) {
284
+ await fs2.mkdir(dir, { recursive: true, mode: 448 });
285
+ const lock = path3.join(dir, "wake.lock");
286
+ const owner = `owner-${process.pid}-${randomBytes2(8).toString("hex")}`;
287
+ const candidate = path3.join(dir, `.${owner}`);
288
+ await fs2.mkdir(candidate, { mode: 448 });
289
+ await fs2.writeFile(path3.join(candidate, owner), "", { mode: 384 });
290
+ let acquired = false;
291
+ try {
292
+ for (let tries = 0; tries < 200; tries++) {
293
+ try {
294
+ await fs2.rename(candidate, lock);
295
+ acquired = true;
296
+ break;
297
+ } catch (error) {
298
+ if (!["EEXIST", "ENOTEMPTY", "EPERM", "EACCES"].includes(error.code ?? "")) throw error;
299
+ }
300
+ await clearAbandonedLock(lock);
301
+ await new Promise((resolve) => setTimeout(resolve, 10));
302
+ }
303
+ } finally {
304
+ if (!acquired) await fs2.rm(candidate, { recursive: true, force: true });
305
+ }
306
+ if (!acquired) throw new Error("wake_state_locked");
307
+ try {
308
+ const data = await readJson2(path3.join(dir, "wake-journal.json"), { attempts: [], state: {} });
309
+ const result = work(data);
310
+ await atomic(path3.join(dir, "wake-journal.json"), data);
311
+ await atomic(path3.join(dir, "wake-state.json"), data.state);
312
+ return result;
313
+ } finally {
314
+ await fs2.rm(path3.join(lock, owner), { force: true });
315
+ try {
316
+ await fs2.rmdir(lock);
317
+ } catch (error) {
318
+ if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code ?? "")) throw error;
319
+ }
320
+ }
321
+ }
322
+ async function readWakeState(dir) {
323
+ return (await readJson2(path3.join(dir, "wake-journal.json"), { attempts: [], state: {} })).state;
324
+ }
325
+ async function recordAttempt(dir, attempt) {
326
+ wakeLine(attempt.nonce);
327
+ if (!attempt.driver || ["__proto__", "constructor", "prototype"].includes(attempt.driver) || !Number.isFinite(attempt.at) || !Number.isFinite(attempt.deadline) || attempt.deadline <= attempt.at) throw new Error("invalid_wake_attempt");
328
+ await locked(dir, (data) => {
329
+ if (data.attempts.some((item) => item.nonce === attempt.nonce)) throw new Error("duplicate_wake_nonce");
330
+ data.attempts.push(attempt);
331
+ });
332
+ }
333
+ async function settleAttempts(dir, input) {
334
+ const snapshot = await readJson2(path3.join(dir, "wake-journal.json"), { attempts: [], state: {} });
335
+ if (!snapshot.attempts.length) return [];
336
+ return locked(dir, (data) => {
337
+ const results = [];
338
+ const remaining = [];
339
+ const activityAt = typeof input.activity?.updatedAt === "number" ? input.activity.updatedAt : Date.parse(input.activity?.updatedAt ?? "");
340
+ for (const attempt of data.attempts) {
341
+ let status;
342
+ const baselineAt = typeof attempt.activityUpdatedAt === "number" ? attempt.activityUpdatedAt : attempt.activityUpdatedAt === void 0 ? attempt.at : Date.parse(attempt.activityUpdatedAt);
343
+ const verified = input.promptText?.includes(`(k-${attempt.nonce})`);
344
+ const activityChanged = input.activity && (input.activity.state !== "idle" || activityAt > baselineAt);
345
+ if (activityChanged) status = "void";
346
+ else if (verified && input.now <= attempt.deadline) status = "success";
347
+ else if (input.now >= attempt.deadline && input.activity?.state === "idle") status = "failure";
348
+ else if (input.promptText !== void 0) status = "void";
349
+ if (!status) {
350
+ remaining.push(attempt);
351
+ continue;
352
+ }
353
+ results.push({ nonce: attempt.nonce, driver: attempt.driver, status });
354
+ if (status === "void") continue;
355
+ const prior = data.state[attempt.driver] ?? { failures: 0 };
356
+ if (status === "success") data.state[attempt.driver] = { failures: 0 };
357
+ else {
358
+ const failures = prior.failures + 1;
359
+ data.state[attempt.driver] = failures >= 2 ? { failures, disabled: true, reason: "nonce_timeout", at: new Date(input.now).toISOString() } : { failures };
360
+ }
361
+ }
362
+ data.attempts = remaining;
363
+ return results;
364
+ });
365
+ }
366
+
367
+ // src/wake/ladder.ts
368
+ function createWakeLadder(deps) {
369
+ if (!SESSION_ID_PATTERN.test(deps.sessionId)) throw new TypeError("invalid_session_id");
370
+ const now = deps.now ?? Date.now;
371
+ const stderr = deps.stderr ?? ((line) => {
372
+ process.stderr.write(line);
373
+ });
374
+ const controller = new AbortController();
375
+ let pending;
376
+ const wakesAtCount = /* @__PURE__ */ new Map();
377
+ const identities = /* @__PURE__ */ new Map();
378
+ const disabledDrivers = /* @__PURE__ */ new Set();
379
+ let stopped = false;
380
+ let again = false;
381
+ let inFlight;
382
+ const warn = (warning, status) => {
383
+ try {
384
+ stderr(JSON.stringify({ ok: false, warning, ...status ? { status } : {} }) + "\n");
385
+ } catch {
386
+ }
387
+ };
388
+ const evaluate = async () => {
389
+ try {
390
+ if (deps.drivers.some((driver2) => driver2.verification !== "none")) await settleAttempts(deps.files.dir, { now: now(), activity: await readActivity(deps.files) });
391
+ const driverStates = await readWakeState(deps.files.dir);
392
+ for (const [id, state] of Object.entries(driverStates)) {
393
+ if (state.disabled && !disabledDrivers.has(id)) {
394
+ disabledDrivers.add(id);
395
+ wakesAtCount.clear();
396
+ pending = void 0;
397
+ }
398
+ }
399
+ const channels = await listChannels(deps.files);
400
+ const eligible = [];
401
+ const currentIdentities = /* @__PURE__ */ new Map();
402
+ for (const channel of channels) {
403
+ const session = await readJson(channel.files.session);
404
+ const metadata = channel.legacy ? null : await readJson(path4.join(channel.files.dir, "channel.json"));
405
+ currentIdentities.set(channel.key, JSON.stringify([session?.roomId ?? channel.roomId, session?.userId, metadata?.joinedAt]));
406
+ if (await readListeningMode(channel.files) === "async") continue;
407
+ eligible.push({
408
+ channel,
409
+ counts: { messages: (await unread(channel.files)).entries.filter((entry) => entry.kind === "message" && entry.sender !== session?.userId).length },
410
+ cursor: await readCursor(channel.files)
411
+ });
412
+ }
413
+ const keys = new Set(currentIdentities.keys());
414
+ const changed = /* @__PURE__ */ new Set();
415
+ for (const [key, identity] of currentIdentities) {
416
+ if (identities.get(key) !== identity) changed.add(key);
417
+ }
418
+ for (const key of identities.keys()) if (!keys.has(key)) changed.add(key);
419
+ if (changed.size) {
420
+ for (const [key, wakes2] of [...wakesAtCount]) {
421
+ const pairs2 = JSON.parse(key);
422
+ if (!pairs2.some(([id]) => changed.has(id))) continue;
423
+ wakesAtCount.delete(key);
424
+ const remainingPairs = pairs2.filter(([id]) => !changed.has(id));
425
+ if (!remainingPairs.length) continue;
426
+ const remaining = JSON.stringify(remainingPairs);
427
+ wakesAtCount.set(remaining, Math.max(wakes2, wakesAtCount.get(remaining) ?? 0));
428
+ }
429
+ }
430
+ for (const key of changed) identities.delete(key);
431
+ for (const [key, identity] of currentIdentities) identities.set(key, identity);
432
+ const pairs = eligible.map(({ channel, cursor: cursor2 }) => [channel.key, cursor2.deliveredCount]).sort(([a], [b]) => a.localeCompare(b));
433
+ const budgetKey = JSON.stringify(pairs);
434
+ if (changed.size) {
435
+ const inheritedKey = JSON.stringify(pairs.filter(([key]) => !changed.has(key)));
436
+ const newUnread = eligible.some((item) => changed.has(item.channel.key) && item.counts.messages > 0);
437
+ wakesAtCount.set(budgetKey, newUnread ? 0 : wakesAtCount.get(inheritedKey) ?? 0);
438
+ if (newUnread || !eligible.some((item) => !changed.has(item.channel.key) && item.counts.messages > 0)) pending = void 0;
439
+ }
440
+ if (!eligible.some((item) => item.counts.messages > 0)) {
441
+ pending = void 0;
442
+ return;
443
+ }
444
+ const activity = await readActivity(deps.files);
445
+ if (pending && Date.parse(activity.updatedAt) > pending.at) pending = void 0;
446
+ const at = now();
447
+ if (pending && at - pending.at < (deps.retryAfterMs ?? 6e4)) return;
448
+ if (stopped || activity.state !== "idle") return;
449
+ const wakes = wakesAtCount.get(budgetKey) ?? 0;
450
+ if (wakes >= 2) return;
451
+ for (const { channel } of eligible) {
452
+ if (await readListeningMode(channel.files) === "async") {
453
+ pending = void 0;
454
+ return;
455
+ }
456
+ }
457
+ if (stopped) return;
458
+ const ctx = { files: deps.files, harness: deps.harness, sessionId: deps.sessionId, env: deps.env ?? process.env, signal: controller.signal, now: at };
459
+ const driver = await chooseWakeDriver(deps.drivers, ctx, activity);
460
+ if (!driver || stopped) return;
461
+ const stillEligible = async () => {
462
+ if (stopped) return false;
463
+ if (!await driverAllowed(stateRoot(ctx.env), ctx.harness, driver.id, driver.optIn)) return false;
464
+ if ((await readWakeState(deps.files.dir))[driver.id]?.disabled) return false;
465
+ for (const { channel } of eligible) {
466
+ if (await readListeningMode(channel.files) === "async") return false;
467
+ }
468
+ const current = await readActivity(deps.files);
469
+ return !stopped && current.state === "idle" && current.updatedAt === activity.updatedAt;
470
+ };
471
+ if (!await stillEligible()) return;
472
+ const attemptAt = now();
473
+ const nonce = newNonce();
474
+ if (driver.verification !== "none") {
475
+ await recordAttempt(deps.files.dir, { nonce, driver: driver.id, at: attemptAt, deadline: attemptAt + driver.deadlineMs, activityUpdatedAt: activity.updatedAt });
476
+ if (!await stillEligible()) {
477
+ await settleAttempts(deps.files.dir, { now: now(), activity: await readActivity(deps.files), promptText: "" });
478
+ return;
479
+ }
480
+ }
481
+ if (stopped) return;
482
+ pending = { at: attemptAt };
483
+ wakesAtCount.set(budgetKey, wakes + 1);
484
+ await driver.wake({ ...ctx, now: attemptAt }, wakeLine(nonce));
485
+ } catch {
486
+ warn(`${deps.warningPrefix ?? "wake"}_waker_error`);
487
+ }
488
+ };
489
+ const notify = () => {
490
+ if (stopped) return;
491
+ if (inFlight) {
492
+ again = true;
493
+ return;
494
+ }
495
+ inFlight = (async () => {
496
+ do {
497
+ again = false;
498
+ await evaluate();
499
+ } while (again && !stopped);
500
+ })().finally(() => {
501
+ inFlight = void 0;
502
+ if (again && !stopped) notify();
503
+ });
504
+ };
505
+ const timer = setInterval(notify, deps.pollMs ?? 1e3);
506
+ timer.unref();
507
+ return {
508
+ notify,
509
+ async stop() {
510
+ stopped = true;
511
+ again = false;
512
+ clearInterval(timer);
513
+ controller.abort();
514
+ await inFlight;
515
+ }
516
+ };
517
+ }
518
+ async function chooseWakeDriver(drivers, ctx, activity) {
519
+ if (activity.state !== "idle") return void 0;
520
+ const states = await readWakeState(ctx.files.dir);
521
+ const override = ctx.env.KHALA_WAKE_TEST_IDLE_MS;
522
+ const idleOverride = override !== void 0 && /^\d+$/.test(override) ? Number(override) : void 0;
523
+ for (const driver of [...drivers].sort((a, b) => a.rung - b.rung)) {
524
+ if (states[driver.id]?.disabled) continue;
525
+ if (!await driverAllowed(stateRoot(ctx.env), ctx.harness, driver.id, driver.optIn)) continue;
526
+ if (!await driver.available(ctx)) continue;
527
+ if (ctx.now - Date.parse(activity.updatedAt) < (idleOverride ?? driver.minIdleMs)) return void 0;
528
+ return driver;
529
+ }
530
+ return void 0;
531
+ }
532
+
533
+ // src/wake/codex.ts
534
+ function createCodexWakeDriver(deps = {}) {
535
+ return {
536
+ id: "queue",
537
+ rung: 1,
538
+ optIn: false,
539
+ minIdleMs: 0,
540
+ deadlineMs: 3e4,
541
+ verification: "nonce",
542
+ available: async (ctx) => (await (deps.probe ?? probeCodexQueue)(ctx.env)).available,
543
+ unavailableReason: async (ctx) => (await (deps.probe ?? probeCodexQueue)(ctx.env)).reason,
544
+ async wake(ctx, line) {
545
+ const probe = await (deps.probe ?? probeCodexQueue)(ctx.env);
546
+ if (!probe.available || ctx.signal.aborted) return;
547
+ const port = deps.port ?? createCodexQueueProcessPort({ command: probe.command, env: ctx.env });
548
+ const outcome = await port.run(codexIdleWakeArgv(ctx.sessionId, line), ctx.signal);
549
+ if (outcome.status !== "queued") {
550
+ const line2 = JSON.stringify({ ok: false, warning: "codex_queue_failed", status: outcome.status }) + "\n";
551
+ try {
552
+ (deps.stderr ?? ((value) => {
553
+ process.stderr.write(value);
554
+ }))(line2);
555
+ } catch {
556
+ }
557
+ }
558
+ }
559
+ };
560
+ }
561
+ var codexWakeDriver = createCodexWakeDriver();
562
+
563
+ // src/harness/codex.ts
564
+ var install = async (flags, deps) => (await import("./chunk-5PUQT3YS.mjs")).runCodexInstall(flags, deps);
565
+ var codex = {
566
+ id: "codex",
567
+ // A string metadata id wins even when empty or invalid; validation happens after selection.
568
+ sessionSources: [
569
+ { kind: "meta", resolve: (meta) => typeof meta?.threadId === "string" ? meta.threadId : void 0, rejoinable: () => true },
570
+ { kind: "env", resolve: (_meta, env) => env.CODEX_THREAD_ID, rejoinable: () => true }
571
+ ],
572
+ codec: claudeStyleCodec,
573
+ restoreAtStartup: true,
574
+ install,
575
+ uninstall: (flags, deps) => install([...flags, "--uninstall"], deps),
576
+ wakeLadder: [codexWakeDriver],
577
+ wakeWarningName: "codex"
578
+ };
579
+
580
+ // src/cursor.ts
581
+ import { createHash } from "node:crypto";
582
+ var CURSOR_WORKSPACE_ENV = "KHALA_CURSOR_WORKSPACE";
583
+ var CURSOR_DEFAULT_SESSION = "cursor-default";
584
+ function normalizeWorkspace(root) {
585
+ let value = root.trim();
586
+ if (!value || value.includes("${")) return null;
587
+ if (/^file:\/\//iu.test(value)) {
588
+ try {
589
+ value = decodeURIComponent(new URL(value).pathname);
590
+ } catch {
591
+ return null;
592
+ }
593
+ }
594
+ value = value.replaceAll("\\", "/");
595
+ if (/^\/[A-Za-z]:(?:\/|$)/u.test(value)) value = value.slice(1);
596
+ if (/^[A-Za-z]:(?:\/|$)/u.test(value)) value = value.toLowerCase();
597
+ value = value.replace(/\/+$/u, "");
598
+ if (/^[a-z]:$/u.test(value)) value += "/";
599
+ return value || null;
600
+ }
601
+ function cursorSessionId(root) {
602
+ const normalized = typeof root === "string" ? normalizeWorkspace(root) : null;
603
+ if (normalized === null) return CURSOR_DEFAULT_SESSION;
604
+ return `ws-${createHash("sha256").update(normalized).digest("hex").slice(0, 32)}`;
605
+ }
606
+
607
+ // src/harness/codecs/cursor.ts
608
+ var events2 = { beforeSubmitPrompt: "prompt", postToolUse: "tool", stop: "stop" };
609
+ var cursorCodec = {
610
+ promptDeliversWithoutWake: false,
611
+ promptAcceptsContext: false,
612
+ suppressOutputErrors: true,
613
+ fallbackSession: CURSOR_DEFAULT_SESSION,
614
+ parse(stdin) {
615
+ try {
616
+ const input = JSON.parse(stdin.replace(/^\uFEFF/u, ""));
617
+ if (!input || typeof input.hook_event_name !== "string" || !Object.hasOwn(events2, input.hook_event_name)) return null;
618
+ const workspace = Array.isArray(input.workspace_roots) && typeof input.workspace_roots[0] === "string" ? input.workspace_roots[0] : void 0;
619
+ return {
620
+ sessionId: cursorSessionId(workspace),
621
+ event: events2[input.hook_event_name],
622
+ continuation: typeof input.loop_count === "number" && input.loop_count > 0,
623
+ ...typeof input.prompt === "string" ? { promptText: input.prompt } : {},
624
+ ...workspace !== void 0 ? { workspace } : {}
625
+ };
626
+ } catch {
627
+ return null;
628
+ }
629
+ },
630
+ noop: (kind) => JSON.stringify(kind === "prompt" ? { continue: true } : {}) + "\n",
631
+ render(kind, frame) {
632
+ if (kind === "prompt") return cursorCodec.noop(kind);
633
+ return JSON.stringify(kind === "stop" ? { followup_message: frame } : { additional_context: frame }) + "\n";
634
+ }
635
+ };
636
+
637
+ // src/harness/cursor.ts
638
+ var install2 = async (flags, deps) => (await import("./chunk-5PUQT3YS.mjs")).runCursorInstall(flags, deps);
639
+ var cursor = {
640
+ id: "cursor",
641
+ sessionSources: [{
642
+ kind: "workspace",
643
+ resolve: (_meta, env) => cursorSessionId(env[CURSOR_WORKSPACE_ENV]),
644
+ rejoinable: (sessionId) => sessionId !== CURSOR_DEFAULT_SESSION
645
+ }],
646
+ codec: cursorCodec,
647
+ restoreAtStartup: false,
648
+ install: install2,
649
+ uninstall: (flags, deps) => install2([...flags, "--uninstall"], deps)
650
+ };
651
+
652
+ // src/harness/codecs/opencode.ts
653
+ var events3 = { "session-start": "start", prompt: "prompt", "post-tool": "tool", "turn-end": "stop", idle: "stop" };
654
+ var opencodeCodec = {
655
+ promptAcceptsContext: true,
656
+ promptDeliversWithoutWake: true,
657
+ suppressOutputErrors: false,
658
+ parse(stdin) {
659
+ try {
660
+ const input = JSON.parse(stdin.replace(/^\uFEFF/u, ""));
661
+ if (!input || typeof input.session_id !== "string" || typeof input.event !== "string" || !Object.hasOwn(events3, input.event)) return null;
662
+ return {
663
+ sessionId: input.session_id,
664
+ event: events3[input.event],
665
+ continuation: false,
666
+ ...typeof input.prompt === "string" ? { promptText: input.prompt } : {}
667
+ };
668
+ } catch {
669
+ return null;
670
+ }
671
+ },
672
+ noop: () => "",
673
+ render: (_kind, frame) => frame
674
+ };
675
+
676
+ // src/harness/session-sources.ts
677
+ import path5 from "node:path";
678
+ import { lstat } from "node:fs/promises";
679
+
680
+ // src/harness/proc.ts
681
+ import { execFile as execFile2 } from "node:child_process";
682
+ import { readFile as readFile3 } from "node:fs/promises";
683
+ import { promisify as promisify2 } from "node:util";
684
+ var execFileAsync = promisify2(execFile2);
685
+ var validPid = (pid) => Number.isSafeInteger(pid) && pid > 0;
686
+ function startToken(value) {
687
+ return value.trim().replace(/[^a-zA-Z0-9_-]+/g, "-");
688
+ }
689
+ function parseLinuxStat(pid, text) {
690
+ const open = text.indexOf("(");
691
+ const close = text.lastIndexOf(")");
692
+ if (!validPid(pid) || open < 0 || close < open || Number(text.slice(0, open).trim()) !== pid) return null;
693
+ const fields = text.slice(close + 1).trim().split(/\s+/);
694
+ const ppid = Number(fields[1]);
695
+ const startTime = fields[19];
696
+ if (!Number.isSafeInteger(ppid) || ppid < 0 || !startTime || !/^\d+$/.test(startTime)) return null;
697
+ return { pid, ppid, startTime, command: text.slice(open + 1, close) };
698
+ }
699
+ function parseDarwinPs(pid, text) {
700
+ const match = text.trim().match(/^(\d+)\s+([A-Za-z]{3}\s+[A-Za-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/);
701
+ if (!validPid(pid) || !match) return null;
702
+ return { pid, ppid: Number(match[1]), startTime: startToken(match[2]), command: match[3] };
703
+ }
704
+ function csvFields(line) {
705
+ const fields = [];
706
+ let value = "";
707
+ let quoted = false;
708
+ for (let i = 0; i < line.length; i++) {
709
+ const char = line[i];
710
+ if (char === '"') {
711
+ if (quoted && line[i + 1] === '"') {
712
+ value += '"';
713
+ i++;
714
+ } else quoted = !quoted;
715
+ } else if (char === "," && !quoted) {
716
+ fields.push(value);
717
+ value = "";
718
+ } else value += char;
719
+ }
720
+ if (quoted) return null;
721
+ fields.push(value);
722
+ return fields;
723
+ }
724
+ function parseWindowsCsv(text, pid) {
725
+ if (!validPid(pid)) return null;
726
+ const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/).filter((line) => line.trim() && !line.startsWith("#"));
727
+ const headers = csvFields(lines[0] ?? "");
728
+ if (!headers) return null;
729
+ for (const line of lines.slice(1)) {
730
+ const values = csvFields(line);
731
+ if (!values) continue;
732
+ const field = (name) => values[headers.indexOf(name)] ?? "";
733
+ if (Number(field("ProcessId")) !== pid) continue;
734
+ const ppid = Number(field("ParentProcessId"));
735
+ const startTime = startToken(field("CreationDate"));
736
+ const command = field("Name");
737
+ if (!field("ParentProcessId") || !Number.isSafeInteger(ppid) || ppid < 0 || !startTime || !command) return null;
738
+ return { pid, ppid, startTime, command };
739
+ }
740
+ return null;
741
+ }
742
+ async function readProcess(pid) {
743
+ if (!validPid(pid)) return null;
744
+ try {
745
+ if (process.platform === "linux") return parseLinuxStat(pid, await readFile3(`/proc/${pid}/stat`, "utf8"));
746
+ if (process.platform === "darwin") {
747
+ const { stdout } = await execFileAsync("ps", ["-o", "ppid=,lstart=,comm=", "-p", String(pid)], { env: { ...process.env, LC_ALL: "C" }, timeout: 5e3 });
748
+ return parseDarwinPs(pid, stdout);
749
+ }
750
+ if (process.platform === "win32") {
751
+ const script = `Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object ProcessId,ParentProcessId,@{Name='CreationDate';Expression={$_.CreationDate.ToUniversalTime().ToString('o')}},Name | ConvertTo-Csv -NoTypeInformation`;
752
+ const { stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: 5e3 });
753
+ return parseWindowsCsv(stdout, pid);
754
+ }
755
+ } catch {
756
+ }
757
+ return null;
758
+ }
759
+ async function* walkAncestors(pid, read = readProcess) {
760
+ const seen = /* @__PURE__ */ new Set([pid]);
761
+ let current = await read(pid);
762
+ while (current && validPid(current.ppid) && !seen.has(current.ppid)) {
763
+ seen.add(current.ppid);
764
+ current = await read(current.ppid);
765
+ if (current) yield current;
766
+ }
767
+ }
768
+ async function nearestNonShellAncestor(pid, read = readProcess) {
769
+ for await (const ancestor of walkAncestors(pid, read)) {
770
+ const name = ancestor.command.split(/[\\/]/).pop()?.replace(/^-/, "").toLowerCase() ?? "";
771
+ if (!/^(?:sh|bash|dash|zsh|fish|ksh|csh|tcsh|ash|cmd(?:\.exe)?|powershell(?:\.exe)?|pwsh(?:\.exe)?)$/.test(name)) return ancestor;
772
+ }
773
+ return null;
774
+ }
775
+
776
+ // src/harness/session-sources.ts
777
+ var SESSION_UNKNOWN_HINT = "Send the agent one message first, then retry.";
778
+ var mappingDir = (harness, env) => path5.join(stateRoot(env), harness, ".by-pid");
779
+ async function recordHookSession(harness, sessionId, env, options = {}) {
780
+ if (!SESSION_ID_PATTERN.test(sessionId)) return;
781
+ const parent = await nearestNonShellAncestor(options.pid ?? process.pid, options.readProcess ?? readProcess);
782
+ if (!parent) return;
783
+ const dir = mappingDir(harness, env);
784
+ await ensureStateDir(dir);
785
+ const file = path5.join(dir, `${parent.pid}.json`);
786
+ const at = (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
787
+ const previous = await readJson(file);
788
+ if (previous?.startTime === parent.startTime && typeof previous.at === "string" && previous.at > at) return;
789
+ await writeJsonAtomic(file, {
790
+ sessionId,
791
+ startTime: parent.startTime,
792
+ at,
793
+ ...options.workspace !== void 0 ? { workspace: options.workspace } : {}
794
+ });
795
+ }
796
+ var hookMapSource = {
797
+ kind: "hook-map",
798
+ rejoinable: () => true,
799
+ async resolve(_meta, env, context) {
800
+ const dir = mappingDir(context.harness, env);
801
+ try {
802
+ await lstat(dir);
803
+ } catch (error) {
804
+ if (error.code === "ENOENT") return null;
805
+ throw error;
806
+ }
807
+ await ensureStateDir(dir);
808
+ const parent = await nearestNonShellAncestor(context.pid ?? process.pid, context.readProcess ?? readProcess);
809
+ if (!parent) return null;
810
+ const entry = await readJson(path5.join(dir, `${parent.pid}.json`));
811
+ if (entry?.startTime === parent.startTime && typeof entry.sessionId === "string" && SESSION_ID_PATTERN.test(entry.sessionId) && typeof entry.at === "string" && Number.isFinite(Date.parse(entry.at))) return entry.sessionId;
812
+ return null;
813
+ }
814
+ };
815
+ var processSource = {
816
+ kind: "process",
817
+ rejoinable: () => false,
818
+ async resolve(_meta, _env, context) {
819
+ for await (const parent of walkAncestors(context.pid ?? process.pid, context.readProcess ?? readProcess)) {
820
+ return `proc-${parent.pid}-${parent.startTime}`;
821
+ }
822
+ return null;
823
+ }
824
+ };
825
+ async function resolveSources(sources, meta, env, context) {
826
+ for (const source of sources) {
827
+ const value = await source.resolve(meta, env, context);
828
+ if (value === void 0 || value === null) continue;
829
+ return typeof value === "string" && SESSION_ID_PATTERN.test(value) ? { sessionId: value, rejoinable: source.rejoinable(value) } : null;
830
+ }
831
+ return null;
832
+ }
833
+
834
+ // src/harness/opencode.ts
835
+ var install3 = async (flags, deps) => (await import("./chunk-5PUQT3YS.mjs")).runOpenCodeInstall(flags, deps);
836
+ var opencode = {
837
+ id: "opencode",
838
+ sessionSources: [{ kind: "meta", resolve: (meta) => meta?.khala_session, rejoinable: () => true }, hookMapSource],
839
+ codec: opencodeCodec,
840
+ restoreAtStartup: false,
841
+ install: install3,
842
+ uninstall: (flags, deps) => install3([...flags, "--uninstall"], deps)
843
+ };
844
+
845
+ // src/harness/generic.ts
846
+ var generic = {
847
+ id: "generic",
848
+ sessionSources: [
849
+ { kind: "env", resolve: (_meta, env) => env.KHALA_SESSION_ID, rejoinable: () => true },
850
+ processSource
851
+ ],
852
+ codec: void 0,
853
+ restoreAtStartup: true
854
+ };
855
+ function genericAdapter(id) {
856
+ return { ...generic, id };
857
+ }
858
+
859
+ // src/harness/index.ts
860
+ function createAdapterRegistry(adapters) {
861
+ const byId = /* @__PURE__ */ new Map();
862
+ for (const adapter of adapters) {
863
+ if (byId.has(adapter.id)) throw new Error(`duplicate_adapter: ${adapter.id}`);
864
+ if (!HARNESS_REGISTRY.some((row) => row.id === adapter.id)) throw new Error(`unregistered_adapter: ${adapter.id}`);
865
+ byId.set(adapter.id, adapter);
866
+ }
867
+ return Object.freeze({ resolve: (id) => byId.get(id) });
868
+ }
869
+ var ADAPTERS = Object.freeze([claude, codex, cursor, opencode, generic]);
870
+ var registry = createAdapterRegistry(ADAPTERS);
871
+ function adapterFor(id) {
872
+ return registry.resolve(id) ?? (isHarnessId(id) ? genericAdapter(id) : void 0);
873
+ }
874
+
875
+ export {
876
+ readListeningMode,
877
+ applyListeningMode,
878
+ readWakeState,
879
+ settleAttempts,
880
+ createWakeLadder,
881
+ CURSOR_WORKSPACE_ENV,
882
+ SESSION_UNKNOWN_HINT,
883
+ recordHookSession,
884
+ resolveSources,
885
+ adapterFor
886
+ };