khala-cli 0.4.2 → 0.4.5

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/dist/{chunk-T6RZE5IQ.mjs → chunk-6LC66VHK.mjs} +10 -7
  2. package/dist/chunk-7G2G4E4U.mjs +236 -0
  3. package/dist/chunk-AUJEUKVM.mjs +121 -0
  4. package/dist/{chunk-NUS6M27N.mjs → chunk-EW22K3JO.mjs} +445 -199
  5. package/dist/chunk-EXHCVFVH.mjs +26 -0
  6. package/dist/{chunk-CDHHFC6C.mjs → chunk-H46OG22B.mjs} +22 -6
  7. package/dist/{chunk-YP45XTOH.mjs → chunk-HB3UGIZG.mjs} +5 -5
  8. package/dist/chunk-IR3VN2ID.mjs +52 -0
  9. package/dist/{chunk-B5BFL7OD.mjs → chunk-KCJFSVDN.mjs} +55 -4
  10. package/dist/{chunk-K4MDSFHU.mjs → chunk-MBLP7LQJ.mjs} +103 -38
  11. package/dist/{chunk-RW3DXWNC.mjs → chunk-OLZ62X7S.mjs} +1 -1
  12. package/dist/chunk-P5CP32X2.mjs +170 -0
  13. package/dist/{chunk-A3LJU4JA.mjs → chunk-PXKGKVGW.mjs} +1 -1
  14. package/dist/{chunk-7MQAMABY.mjs → chunk-RSDOTTZQ.mjs} +4 -4
  15. package/dist/chunk-SN3STAO2.mjs +190 -0
  16. package/dist/{chunk-MHZWR4A3.mjs → chunk-SQXDATEC.mjs} +1 -1
  17. package/dist/chunk-T7YRVE43.mjs +238 -0
  18. package/dist/{chunk-BZ53MZEE.mjs → chunk-TZKEQIHP.mjs} +8 -4
  19. package/dist/{chunk-46PTKRZC.mjs → chunk-V4Y26O4R.mjs} +171 -113
  20. package/dist/chunk-V7M27QLT.mjs +21 -0
  21. package/dist/{chunk-2OO725IZ.mjs → chunk-Y2DRTRBJ.mjs} +3 -3
  22. package/dist/{chunk-JRZZVTQ4.mjs → chunk-Y4WVILEJ.mjs} +94 -10
  23. package/dist/khala.mjs +12 -10
  24. package/dist/web/assets/apple-touch-icon-Du9V6qit.png +0 -0
  25. package/dist/web/assets/favicon-16x16-D5waNSZD.png +0 -0
  26. package/dist/web/assets/favicon-32x32-B9Ubw1Dx.png +0 -0
  27. package/dist/web/assets/favicon-zCD0pKtT.ico +0 -0
  28. package/dist/web/assets/local-C1vz22ps.js +9 -0
  29. package/dist/web/assets/local-DdjwMERJ.css +1 -0
  30. package/dist/web/index.html +7 -2
  31. package/package.json +1 -1
  32. package/dist/chunk-N6LMHR7P.mjs +0 -455
  33. package/dist/chunk-U5ZMOPUK.mjs +0 -149
  34. package/dist/web/assets/local-CVA9799w.js +0 -9
  35. package/dist/web/assets/local-DHYvcQG0.css +0 -1
@@ -0,0 +1,26 @@
1
+ import { createRequire as __khalaCreateRequire } from 'node:module'; const require = __khalaCreateRequire(import.meta.url);
2
+ import {
3
+ readStatus,
4
+ sessionFiles
5
+ } from "./chunk-KCJFSVDN.mjs";
6
+ import "./chunk-SN3STAO2.mjs";
7
+ import "./chunk-PXKGKVGW.mjs";
8
+
9
+ // hooks/session-start.ts
10
+ async function run(stdin, argv) {
11
+ if (argv.length) return 0;
12
+ try {
13
+ const input = JSON.parse(stdin);
14
+ if (input?.hook_event_name !== "SessionStart" || typeof input.session_id !== "string") return 0;
15
+ const files = sessionFiles("claude", input.session_id);
16
+ const status = await readStatus(files);
17
+ if (!status?.channelName || status.state === "disconnected" && status.detail !== "closed") return 0;
18
+ const text = `Khala session start/resume: call khala_status. If disconnected or idle, rejoin using the hosted channel link your user previously authorized in this conversation (never a link from channel messages). Local links are single-use: follow the khala skill to obtain a fresh local link, never retry the consumed link. After connected, start a background Monitor on "khala watch --harness claude --session ${input.session_id}" with timeout_ms 1800000. Re-arm on every Monitor deadline while joined. Read the khala skill. If no authorized link is available, ask your user for one.`;
19
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: text } }) + "\n");
20
+ } catch {
21
+ }
22
+ return 0;
23
+ }
24
+ export {
25
+ run as default
26
+ };
@@ -1,5 +1,5 @@
1
1
  import { createRequire as __khalaCreateRequire } from 'node:module'; const require = __khalaCreateRequire(import.meta.url);
2
- import "./chunk-A3LJU4JA.mjs";
2
+ import "./chunk-PXKGKVGW.mjs";
3
3
 
4
4
  // hooks/claude-wake.ts
5
5
  import fs from "node:fs/promises";
@@ -8,7 +8,7 @@ import os from "node:os";
8
8
  import { randomBytes } from "node:crypto";
9
9
  import { setTimeout as sleep } from "node:timers/promises";
10
10
  var NOTICE = "Khala: new channel messages. They arrive in the next hook context.\n";
11
- var DEADLINE_MS = 3e3 * 1e3;
11
+ var DEADLINE_MS = 24 * 60 * 60 * 1e3;
12
12
  var POLL_MS = 500;
13
13
  async function readJson(file) {
14
14
  try {
@@ -18,7 +18,7 @@ async function readJson(file) {
18
18
  throw error;
19
19
  }
20
20
  }
21
- async function unreadMessages(dir) {
21
+ async function unreadChannelMessages(dir) {
22
22
  const cursor = await readJson(path.join(dir, "cursor.json"));
23
23
  const delivered = cursor && (cursor.lastDeliveredEventId === null || typeof cursor.lastDeliveredEventId === "string") && Number.isSafeInteger(cursor.deliveredCount) && typeof cursor.deliveredCount === "number" && cursor.deliveredCount >= 0 ? cursor.deliveredCount : 0;
24
24
  let content;
@@ -41,6 +41,22 @@ async function unreadMessages(dir) {
41
41
  }
42
42
  return messages;
43
43
  }
44
+ async function unreadMessages(dir) {
45
+ const directories = [dir];
46
+ try {
47
+ for (const entry of await fs.readdir(path.join(dir, "channels"), { withFileTypes: true })) {
48
+ if (entry.isDirectory() && !entry.isSymbolicLink()) directories.push(path.join(dir, "channels", entry.name));
49
+ }
50
+ } catch (error) {
51
+ if (error.code !== "ENOENT") throw error;
52
+ }
53
+ let messages = 0;
54
+ for (const directory of directories) {
55
+ if ((await readJson(path.join(directory, "mode.json")))?.mode === "async") continue;
56
+ messages += await unreadChannelMessages(directory);
57
+ }
58
+ return messages;
59
+ }
44
60
  function testDuration(value, fallback) {
45
61
  const number = Number(value);
46
62
  return Number.isSafeInteger(number) && number > 0 ? number : fallback;
@@ -78,11 +94,10 @@ async function watch(stdin, _argv, io = { stderr: process.stderr, env: process.e
78
94
  const activity = await readJson(path.join(dir, "activity.json"));
79
95
  return activity?.state === "idle" && typeof activity.updatedAt === "string" && Number.isFinite(Date.parse(activity.updatedAt));
80
96
  };
81
- const listening = async () => (await readJson(path.join(dir, "mode.json")))?.mode !== "async";
82
97
  while (io.now().getTime() < deadline) {
83
98
  if (!await owns() || !parentAlive(parent)) return 0;
84
- if (await listening() && await idle() && await unreadMessages(dir) > 0) {
85
- if (await unreadMessages(dir) > 0 && await listening() && await idle() && await owns() && parentAlive(parent) && io.now().getTime() < deadline) {
99
+ if (await idle() && await unreadMessages(dir) > 0) {
100
+ if (await unreadMessages(dir) > 0 && await idle() && await owns() && parentAlive(parent) && io.now().getTime() < deadline) {
86
101
  io.stderr.write(NOTICE);
87
102
  return 2;
88
103
  }
@@ -104,6 +119,7 @@ async function run(stdin, argv) {
104
119
  return watch(stdin, argv);
105
120
  }
106
121
  export {
122
+ DEADLINE_MS,
107
123
  run as default,
108
124
  unreadMessages,
109
125
  watch
@@ -3,16 +3,16 @@ import {
3
3
  KhalaClientError,
4
4
  LOCAL_DEFAULT_PORT,
5
5
  decodeHelperFile
6
- } from "./chunk-BZ53MZEE.mjs";
6
+ } from "./chunk-TZKEQIHP.mjs";
7
+ import {
8
+ bundle
9
+ } from "./chunk-OLZ62X7S.mjs";
7
10
  import {
8
11
  StateError,
9
12
  ensureStateDir,
10
13
  readJson,
11
14
  stateRoot
12
- } from "./chunk-B5BFL7OD.mjs";
13
- import {
14
- bundle
15
- } from "./chunk-RW3DXWNC.mjs";
15
+ } from "./chunk-KCJFSVDN.mjs";
16
16
 
17
17
  // src/local/lifecycle.ts
18
18
  import * as fs from "node:fs/promises";
@@ -0,0 +1,52 @@
1
+ import { createRequire as __khalaCreateRequire } from 'node:module'; const require = __khalaCreateRequire(import.meta.url);
2
+ import {
3
+ decodeChannelEvent,
4
+ formatChannelEventLine
5
+ } from "./chunk-P5CP32X2.mjs";
6
+ import {
7
+ readJson,
8
+ writeJsonAtomic
9
+ } from "./chunk-KCJFSVDN.mjs";
10
+
11
+ // src/activity.ts
12
+ import path from "node:path";
13
+ async function readActivity(files) {
14
+ const activity = await readJson(path.join(files.dir, "activity.json"));
15
+ return activity && ["idle", "busy"].includes(activity.state) && typeof activity.updatedAt === "string" && Number.isFinite(Date.parse(activity.updatedAt)) ? activity : { state: "busy", updatedAt: (/* @__PURE__ */ new Date(0)).toISOString() };
16
+ }
17
+ async function writeActivity(files, state, now = () => /* @__PURE__ */ new Date()) {
18
+ const activity = { state, updatedAt: now().toISOString() };
19
+ await writeJsonAtomic(path.join(files.dir, "activity.json"), activity);
20
+ return activity;
21
+ }
22
+
23
+ // src/events/receive.ts
24
+ function toEventInboxEntry(base, rawContent) {
25
+ const decoded = decodeChannelEvent(rawContent);
26
+ if (!decoded.ok) return null;
27
+ const content = decoded.value;
28
+ return {
29
+ entry: { ...base, kind: "event", body: formatChannelEventLine(content) + (content.url ? " " + content.url : "") },
30
+ ...content.key !== void 0 ? { key: content.key } : {}
31
+ };
32
+ }
33
+ function createEventKeyFilter() {
34
+ const seen = /* @__PURE__ */ new Set();
35
+ return (key) => {
36
+ if (key === void 0) return true;
37
+ if (seen.has(key)) return false;
38
+ seen.add(key);
39
+ return true;
40
+ };
41
+ }
42
+ function isWakeEntry(entry) {
43
+ return entry.kind === "message";
44
+ }
45
+
46
+ export {
47
+ readActivity,
48
+ writeActivity,
49
+ toEventInboxEntry,
50
+ createEventKeyFilter,
51
+ isWakeEntry
52
+ };
@@ -1,12 +1,12 @@
1
1
  import { createRequire as __khalaCreateRequire } from 'node:module'; const require = __khalaCreateRequire(import.meta.url);
2
2
  import {
3
3
  HARNESSES
4
- } from "./chunk-N6LMHR7P.mjs";
4
+ } from "./chunk-SN3STAO2.mjs";
5
5
 
6
6
  // src/state.ts
7
7
  import { constants } from "node:fs";
8
8
  import * as fs from "node:fs/promises";
9
- import { randomUUID } from "node:crypto";
9
+ import { createHash, randomUUID } from "node:crypto";
10
10
  import os from "node:os";
11
11
  import path from "node:path";
12
12
  var SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
@@ -38,6 +38,38 @@ function stateRoot(env = process.env) {
38
38
  function filesForDir(dir) {
39
39
  return { dir, mode: path.join(dir, "mode.json"), join: path.join(dir, "join.json"), session: path.join(dir, "session.json"), inbox: path.join(dir, "inbox.jsonl"), cursor: path.join(dir, "cursor.json"), status: path.join(dir, "status.json") };
40
40
  }
41
+ function stateKey(value) {
42
+ return createHash("sha256").update(value).digest("hex").slice(0, 24);
43
+ }
44
+ function channelsDir(files) {
45
+ return path.join(files.dir, "channels");
46
+ }
47
+ function channelFiles(files, roomId) {
48
+ return filesForDir(path.join(channelsDir(files), stateKey(roomId)));
49
+ }
50
+ function joinFilePath(files, key) {
51
+ if (!/^[a-f0-9]{24}$/.test(key)) throw new StateError("storage_failed");
52
+ return path.join(files.dir, "joins", `${key}.json`);
53
+ }
54
+ async function writeJoinFile(files, link, join) {
55
+ const file = joinFilePath(files, stateKey(link));
56
+ await ensureStateDir(path.dirname(file));
57
+ await writeJsonAtomic(file, join);
58
+ }
59
+ async function readJoinFile(files, link) {
60
+ const file = joinFilePath(files, stateKey(link));
61
+ await ensureStateDir(path.dirname(file));
62
+ return readJson(file);
63
+ }
64
+ async function removeJoinFile(files, link) {
65
+ const file = joinFilePath(files, stateKey(link));
66
+ await ensureStateDir(path.dirname(file));
67
+ try {
68
+ await fs.unlink(file);
69
+ } catch (error) {
70
+ if (error.code !== "ENOENT") throw new StateError("storage_failed");
71
+ }
72
+ }
41
73
  function sessionFiles(harness, sessionId, env) {
42
74
  if (!HARNESSES.includes(harness) || !SESSION_ID_PATTERN.test(sessionId)) throw new StateError("invalid_session_id");
43
75
  return filesForDir(path.join(stateRoot(env), harness, sessionId));
@@ -47,7 +79,20 @@ function resolveStateDir(harness, sessionId, env) {
47
79
  }
48
80
  async function ensureStateDir(dir) {
49
81
  try {
50
- for (const directory of [path.dirname(path.dirname(dir)), path.dirname(dir), dir]) {
82
+ let sessionDir = dir;
83
+ if (path.basename(path.dirname(dir)) === "channels" && /^[a-f0-9]{24}$/.test(path.basename(dir)) && HARNESSES.includes(path.basename(path.dirname(path.dirname(path.dirname(dir)))))) {
84
+ sessionDir = path.dirname(path.dirname(dir));
85
+ } else if (["channels", "joins"].includes(path.basename(dir)) && HARNESSES.includes(path.basename(path.dirname(path.dirname(dir))))) {
86
+ sessionDir = path.dirname(dir);
87
+ }
88
+ const directories = [path.dirname(path.dirname(sessionDir)), path.dirname(sessionDir), sessionDir];
89
+ const relative = path.relative(sessionDir, dir);
90
+ let parent = sessionDir;
91
+ for (const component of relative ? relative.split(path.sep) : []) {
92
+ parent = path.join(parent, component);
93
+ directories.push(parent);
94
+ }
95
+ for (const directory of directories) {
51
96
  try {
52
97
  await fs.mkdir(directory, { recursive: true, mode: 448 });
53
98
  } catch (error) {
@@ -105,7 +150,7 @@ function readStatus(files) {
105
150
  return readJson(files.status);
106
151
  }
107
152
  async function writeStateFile(dir, name, value) {
108
- if (!["join.json", "session.json", "cursor.json", "status.json", "mode.json", "rejoin.json"].includes(name)) throw new StateError("storage_failed");
153
+ if (!["join.json", "session.json", "cursor.json", "status.json", "mode.json", "rejoin.json", "channel.json"].includes(name)) throw new StateError("storage_failed");
109
154
  await writeJsonAtomic(path.join(dir, name), value);
110
155
  }
111
156
  function readStateFile(dir, name) {
@@ -126,6 +171,12 @@ export {
126
171
  StateError,
127
172
  stateRoot,
128
173
  filesForDir,
174
+ stateKey,
175
+ channelsDir,
176
+ channelFiles,
177
+ writeJoinFile,
178
+ readJoinFile,
179
+ removeJoinFile,
129
180
  sessionFiles,
130
181
  resolveStateDir,
131
182
  ensureStateDir,
@@ -1,22 +1,27 @@
1
1
  import { createRequire as __khalaCreateRequire } from 'node:module'; const require = __khalaCreateRequire(import.meta.url);
2
2
  import {
3
- advanceCursor,
4
3
  isWakeEntry,
5
- readListeningMode,
6
- unread,
7
4
  writeActivity
8
- } from "./chunk-U5ZMOPUK.mjs";
5
+ } from "./chunk-IR3VN2ID.mjs";
6
+ import "./chunk-P5CP32X2.mjs";
9
7
  import {
10
- StateError,
11
- readStatus,
12
- sessionFiles
13
- } from "./chunk-B5BFL7OD.mjs";
14
- import "./chunk-N6LMHR7P.mjs";
8
+ advanceCursor,
9
+ listChannels,
10
+ readListeningMode,
11
+ unread
12
+ } from "./chunk-T7YRVE43.mjs";
15
13
  import {
16
14
  CURSOR_DEFAULT_SESSION,
17
15
  cursorSessionId
18
16
  } from "./chunk-VUE2DDYY.mjs";
19
- import "./chunk-A3LJU4JA.mjs";
17
+ import {
18
+ StateError,
19
+ readStatus,
20
+ sessionFiles
21
+ } from "./chunk-KCJFSVDN.mjs";
22
+ import "./chunk-AUJEUKVM.mjs";
23
+ import "./chunk-SN3STAO2.mjs";
24
+ import "./chunk-PXKGKVGW.mjs";
20
25
 
21
26
  // hooks/deliver.ts
22
27
  import * as fs from "node:fs/promises";
@@ -42,7 +47,7 @@ ${INTRO}${youLine}
42
47
  ${entries.map(renderLine).join("\n")}
43
48
  </khala-channel-messages>`;
44
49
  }
45
- function selectFrame(channelName, entries, you) {
50
+ function selectFrame(channelName, entries, you, budget = MAX_FRAME_BYTES, truncate = true) {
46
51
  const rendered = [];
47
52
  let consumedCount = 0;
48
53
  let channel = channelName;
@@ -50,18 +55,18 @@ function selectFrame(channelName, entries, you) {
50
55
  const entry = entries[i];
51
56
  if (rendered.length === 50) break;
52
57
  channel ??= entry.roomId;
53
- if (Buffer.byteLength(renderFrame(channel, [...rendered, entry], you)) > MAX_FRAME_BYTES) {
54
- if (rendered.length) break;
58
+ if (Buffer.byteLength(renderFrame(channel, [...rendered, entry], you)) > budget) {
59
+ if (rendered.length || !truncate) break;
55
60
  const body = Array.from(entry.body);
56
61
  let low = 0, high = body.length;
57
62
  while (low < high) {
58
63
  const middle = Math.ceil((low + high) / 2);
59
64
  const candidate = { ...entry, body: body.slice(0, middle).join("") + TRUNCATED };
60
- if (Buffer.byteLength(renderFrame(channel, [candidate], you)) <= MAX_FRAME_BYTES) low = middle;
65
+ if (Buffer.byteLength(renderFrame(channel, [candidate], you)) <= budget) low = middle;
61
66
  else high = middle - 1;
62
67
  }
63
68
  const truncated = { ...entry, body: body.slice(0, low).join("") + TRUNCATED };
64
- if (Buffer.byteLength(renderFrame(channel, [truncated], you)) > MAX_FRAME_BYTES) throw new Error("frame_metadata_too_large");
69
+ if (Buffer.byteLength(renderFrame(channel, [truncated], you)) > budget) throw new Error("frame_metadata_too_large");
65
70
  rendered.push(truncated);
66
71
  consumedCount = i + 1;
67
72
  break;
@@ -74,6 +79,82 @@ function selectFrame(channelName, entries, you) {
74
79
  consumed: entries.slice(0, consumedCount)
75
80
  };
76
81
  }
82
+ function selectFrames(groups, budget = MAX_FRAME_BYTES) {
83
+ const ordered = [...groups].filter((group) => group.entries.length).sort((a, b) => Date.parse(a.entries[0].ts) - Date.parse(b.entries[0].ts));
84
+ const selected = [];
85
+ let used = 0;
86
+ for (const group of ordered) {
87
+ const result = selectFrame(
88
+ group.channel.channelName ?? group.channel.roomId,
89
+ group.entries,
90
+ group.you,
91
+ budget - used - (selected.length ? 1 : 0),
92
+ selected.length === 0
93
+ );
94
+ if (!result.frame) continue;
95
+ const bytes = Buffer.byteLength(result.frame) + (selected.length ? 1 : 0);
96
+ if (used + bytes > budget) continue;
97
+ selected.push({ ...group, ...result });
98
+ used += bytes;
99
+ }
100
+ return selected;
101
+ }
102
+ async function channelFrames(files, tool, requireWake, io) {
103
+ const groups = [];
104
+ for (const channel of await listChannels(files)) {
105
+ const mode = await readListeningMode(channel.files);
106
+ if (mode === "async" || tool && mode !== "steer") continue;
107
+ const [status, pending] = await Promise.all([readStatus(channel.files), unread(channel.files)]);
108
+ groups.push({
109
+ channel: { ...channel, ...status?.channelName !== void 0 ? { channelName: status.channelName } : {} },
110
+ ...pending,
111
+ you: typeof status?.displayName === "string" ? status.displayName : void 0
112
+ });
113
+ }
114
+ const selected = selectFrames(groups);
115
+ const emitted = /* @__PURE__ */ new Map();
116
+ const reserved = selected.reduce((sum, group) => sum + Buffer.byteLength(group.frame), 0) + Math.max(0, selected.length - 1);
117
+ let spare = MAX_FRAME_BYTES - reserved;
118
+ const order = selected.map((group, index) => ({ group, index }));
119
+ if (requireWake) order.sort((a, b) => Number(b.group.consumed.some(isWakeEntry)) - Number(a.group.consumed.some(isWakeEntry)));
120
+ let hasWake = false;
121
+ const failures = [];
122
+ for (const { group, index } of order) {
123
+ if (requireWake && !hasWake && !group.consumed.some(isWakeEntry)) continue;
124
+ try {
125
+ let current = group;
126
+ for (let attempt = 0; attempt < 2; attempt++) {
127
+ if (!current.frame || requireWake && !hasWake && !current.consumed.some(isWakeEntry)) break;
128
+ if (await advanceCursor(current.channel.files, current.cursor, current.consumed) === "conflict") {
129
+ if (attempt === 0) {
130
+ const pending = await unread(current.channel.files);
131
+ const retry = selectFrame(
132
+ current.channel.channelName ?? current.channel.roomId,
133
+ pending.entries,
134
+ current.you,
135
+ Buffer.byteLength(group.frame) + spare,
136
+ index === 0
137
+ );
138
+ current = { ...current, ...pending, ...retry };
139
+ }
140
+ continue;
141
+ }
142
+ spare += Buffer.byteLength(group.frame) - Buffer.byteLength(current.frame);
143
+ emitted.set(index, current.frame);
144
+ hasWake ||= current.consumed.some(isWakeEntry);
145
+ break;
146
+ }
147
+ } catch (error) {
148
+ failures.push(error);
149
+ }
150
+ }
151
+ if (failures.length) {
152
+ if (!emitted.size) throw failures[0];
153
+ const error = failures[0];
154
+ diagnostic(io, error instanceof StateError ? error.code : "internal_error");
155
+ }
156
+ return emitted.size ? [...emitted].sort(([a], [b]) => a - b).map(([, frame]) => frame).join("\n") : null;
157
+ }
77
158
  function diagnostic(io, code) {
78
159
  try {
79
160
  io.stderr.write(JSON.stringify({ ok: false, warning: "khala_hook_suppressed", code }) + "\n");
@@ -110,24 +191,13 @@ async function deliver(stdin, argv, io = {
110
191
  return 0;
111
192
  }
112
193
  try {
113
- const mode = await readListeningMode(files);
114
- if (input.hook_event_name === "PostToolUse" && mode !== "steer") return 0;
115
194
  if (input.hook_event_name === "UserPromptSubmit") await writeActivity(files, "busy", io.now);
116
195
  if (input.hook_event_name === "Stop" && input.stop_hook_active === true) {
117
196
  await writeActivity(files, "idle", io.now);
118
197
  return 0;
119
198
  }
120
- if (mode === "async") {
121
- if (input.hook_event_name === "Stop") await writeActivity(files, "idle", io.now);
122
- return 0;
123
- }
124
- const status = await readStatus(files);
125
- for (let attempt = 0; attempt < 2; attempt++) {
126
- const { entries, cursor } = await unread(files);
127
- const { frame, consumed } = selectFrame(status?.channelName, entries, typeof status?.displayName === "string" ? status.displayName : void 0);
128
- if (!frame) break;
129
- if (input.hook_event_name !== "UserPromptSubmit" && !consumed.some(isWakeEntry)) break;
130
- if (await advanceCursor(files, cursor, consumed) === "conflict") continue;
199
+ const frame = await channelFrames(files, input.hook_event_name === "PostToolUse", input.hook_event_name !== "UserPromptSubmit", io);
200
+ if (frame) {
131
201
  await writeActivity(files, "busy", io.now);
132
202
  const envelope = input.hook_event_name === "Stop" ? { decision: "block", reason: frame } : { hookSpecificOutput: { hookEventName: input.hook_event_name, additionalContext: frame } };
133
203
  io.stdout.write(JSON.stringify(envelope) + "\n");
@@ -176,22 +246,16 @@ async function deliverCursor(stdin, io) {
176
246
  return 0;
177
247
  }
178
248
  async function cursorOutput(files, event, loopCount, io) {
179
- const mode = await readListeningMode(files);
180
249
  if (event === "beforeSubmitPrompt") {
181
250
  await writeActivity(files, "busy", io.now);
182
251
  return null;
183
252
  }
184
- if (event === "postToolUse" && mode !== "steer") return null;
185
- if (event === "stop" && (mode === "async" || typeof loopCount === "number" && loopCount > 0)) {
253
+ if (event === "stop" && typeof loopCount === "number" && loopCount > 0) {
186
254
  await writeActivity(files, "idle", io.now);
187
255
  return null;
188
256
  }
189
- const status = await readStatus(files);
190
- for (let attempt = 0; attempt < 2; attempt++) {
191
- const { entries, cursor } = await unread(files);
192
- const { frame, consumed } = selectFrame(status?.channelName, entries, typeof status?.displayName === "string" ? status.displayName : void 0);
193
- if (!frame || !consumed.some(isWakeEntry)) break;
194
- if (await advanceCursor(files, cursor, consumed) === "conflict") continue;
257
+ const frame = await channelFrames(files, event === "postToolUse", true, io);
258
+ if (frame) {
195
259
  await writeActivity(files, "busy", io.now);
196
260
  return event === "stop" ? { followup_message: frame } : { additional_context: frame };
197
261
  }
@@ -203,5 +267,6 @@ export {
203
267
  deliver,
204
268
  deliverCursor,
205
269
  renderFrame,
206
- renderLine
270
+ renderLine,
271
+ selectFrames
207
272
  };
@@ -1,7 +1,7 @@
1
1
  import { createRequire as __khalaCreateRequire } from 'node:module'; const require = __khalaCreateRequire(import.meta.url);
2
2
  import {
3
3
  define_KHALA_BUNDLE_default
4
- } from "./chunk-A3LJU4JA.mjs";
4
+ } from "./chunk-PXKGKVGW.mjs";
5
5
 
6
6
  // src/bundle.ts
7
7
  var bundle = typeof define_KHALA_BUNDLE_default === "undefined" ? void 0 : define_KHALA_BUNDLE_default;
@@ -0,0 +1,170 @@
1
+ import { createRequire as __khalaCreateRequire } from 'node:module'; const require = __khalaCreateRequire(import.meta.url);
2
+ import {
3
+ decodeWith,
4
+ displayText,
5
+ fail,
6
+ label,
7
+ literal,
8
+ safeInteger,
9
+ utcTimestamp,
10
+ utf8Length,
11
+ version
12
+ } from "./chunk-SN3STAO2.mjs";
13
+
14
+ // ../contracts/src/m1/channel-event.ts
15
+ var CHANNEL_EVENT_TYPE = "com.khala.event.v1";
16
+ var CHANNEL_EVENT_STATUSES = ["info", "pending", "success", "failure", "attention"];
17
+ var MAX_CHANNEL_EVENT_BYTES = 4096;
18
+ function record(input, path) {
19
+ if (typeof input !== "object" || input === null || Array.isArray(input)) fail(path, "not_object");
20
+ const prototype = Object.getPrototypeOf(input);
21
+ if (prototype !== Object.prototype && prototype !== null) fail(path, "not_object");
22
+ const value = input;
23
+ return {
24
+ has: (key) => Object.hasOwn(value, key) && value[key] !== void 0,
25
+ field: (key) => Object.hasOwn(value, key) ? value[key] : fail(path ? `${path}.${key}` : key, "missing_field"),
26
+ at: (key) => path ? `${path}.${key}` : key
27
+ };
28
+ }
29
+ function nonempty(value, path) {
30
+ if (!value.length) fail(path, "empty");
31
+ return value;
32
+ }
33
+ function display(input, path, maxBytes, maxPoints) {
34
+ const value = nonempty(displayText(input, path, maxBytes), path);
35
+ if ([...value].length > maxPoints) fail(path, "too_long");
36
+ return value;
37
+ }
38
+ function matching(value, path, pattern) {
39
+ if (!pattern.test(value)) fail(path, "invalid_value");
40
+ return value;
41
+ }
42
+ function safely(read) {
43
+ try {
44
+ return read();
45
+ } catch {
46
+ return { ok: false, error: { path: "", code: "invalid_value" } };
47
+ }
48
+ }
49
+ function decodeChannelEvent(raw) {
50
+ return safely(() => decodeWith(() => {
51
+ let serialized;
52
+ try {
53
+ serialized = JSON.stringify(raw);
54
+ } catch {
55
+ fail("", "invalid_value");
56
+ }
57
+ if (serialized !== void 0 && utf8Length(serialized) > MAX_CHANNEL_EVENT_BYTES) fail("", "too_long");
58
+ const r = record(raw, "");
59
+ const v = version(r.field("v"), "v");
60
+ const kind = matching(nonempty(label(r.field("kind"), "kind", 128), "kind"), "kind", /^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/);
61
+ const summary = display(r.field("summary"), "summary", 800, 200);
62
+ if (!summary.trim()) fail("summary", "empty");
63
+ const body = label(r.field("body"), "body", 1024);
64
+ return {
65
+ v,
66
+ kind,
67
+ summary,
68
+ body,
69
+ ...r.has("status") ? { status: literal(r.field("status"), "status", CHANNEL_EVENT_STATUSES) } : {},
70
+ ...r.has("subject") ? { subject: readSubject(r.field("subject")) } : {},
71
+ ...r.has("actor") ? { actor: display(r.field("actor"), "actor", 256, 64) } : {},
72
+ ...r.has("url") ? { url: readUrl(r.field("url")) } : {},
73
+ ...r.has("occurred_at") ? { occurred_at: utcTimestamp(r.field("occurred_at"), "occurred_at") } : {},
74
+ ...r.has("source") ? { source: readSource(r.field("source")) } : {},
75
+ ...r.has("key") ? { key: nonempty(label(r.field("key"), "key", 200), "key") } : {}
76
+ };
77
+ }));
78
+ }
79
+ function readSubject(input) {
80
+ const r = record(input, "subject");
81
+ let pr;
82
+ if (r.has("pr")) {
83
+ pr = safeInteger(r.field("pr"), r.at("pr"));
84
+ if (pr === 0) fail(r.at("pr"), "invalid_value");
85
+ }
86
+ let branch;
87
+ if (r.has("branch")) {
88
+ branch = nonempty(label(r.field("branch"), r.at("branch"), 255), r.at("branch"));
89
+ if (branch.startsWith("refs/")) fail(r.at("branch"), "invalid_value");
90
+ }
91
+ return {
92
+ ...r.has("ticket") ? { ticket: display(r.field("ticket"), r.at("ticket"), 256, 64) } : {},
93
+ ...pr !== void 0 ? { pr } : {},
94
+ ...branch !== void 0 ? { branch } : {},
95
+ ...r.has("repo") ? { repo: matching(label(r.field("repo"), r.at("repo"), 140), r.at("repo"), /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/) } : {},
96
+ ...r.has("sha") ? { sha: matching(label(r.field("sha"), r.at("sha"), 64), r.at("sha"), /^[0-9a-f]{7,64}$/) } : {}
97
+ };
98
+ }
99
+ function readSource(input) {
100
+ const r = record(input, "source");
101
+ return {
102
+ system: nonempty(label(r.field("system"), r.at("system"), 32), r.at("system")),
103
+ ...r.has("topic") ? { topic: label(r.field("topic"), r.at("topic"), 256) } : {},
104
+ ...r.has("event_id") ? { event_id: label(r.field("event_id"), r.at("event_id"), 64) } : {}
105
+ };
106
+ }
107
+ function readUrl(input) {
108
+ if (typeof input !== "string") fail("url", "wrong_type");
109
+ if (utf8Length(input) > 2048) fail("url", "too_long");
110
+ try {
111
+ if (new URL(input).protocol !== "https:") fail("url", "invalid_value");
112
+ } catch {
113
+ fail("url", "invalid_value");
114
+ }
115
+ return input;
116
+ }
117
+ function formatChannelEventLine(content) {
118
+ const { summary, subject } = content;
119
+ const head = subject?.ticket ? `${subject.ticket} ${summary}` : summary;
120
+ const tail = subject?.branch ?? (subject?.pr !== void 0 ? `PR #${subject.pr}` : void 0);
121
+ return tail ? `${head} \xB7 ${tail}` : head;
122
+ }
123
+ function encodeChannelEvent(input) {
124
+ return safely(() => {
125
+ const checked = decodeWith(() => {
126
+ record(input, "");
127
+ return { ...input, v: 1, body: "" };
128
+ });
129
+ if (!checked.ok) return checked;
130
+ const decoded = decodeChannelEvent(checked.value);
131
+ if (!decoded.ok) return decoded;
132
+ return decodeChannelEvent({ ...decoded.value, body: formatChannelEventLine(decoded.value) });
133
+ });
134
+ }
135
+ function memberRenameContent(content, previous) {
136
+ const oldName = previous?.["displayname"];
137
+ const newName = content["displayname"];
138
+ if (previous?.["membership"] !== "join" || content["membership"] !== "join" || typeof oldName !== "string" || !oldName.trim() || typeof newName !== "string" || !newName.trim() || oldName === newName) return null;
139
+ const encoded = encodeChannelEvent({ kind: "member", summary: `${oldName} is now ${newName}`, status: "info", source: { system: "khala" } });
140
+ return encoded.ok ? encoded.value : null;
141
+ }
142
+ function statusFor(content) {
143
+ if (content.status !== void 0) return content.status;
144
+ switch (content.kind) {
145
+ case "ci.passed":
146
+ case "pr.merged":
147
+ case "agent.unblocked":
148
+ return "success";
149
+ case "ci.failed":
150
+ return "failure";
151
+ case "pr.ready_for_review":
152
+ case "pr.parked_ready":
153
+ return "pending";
154
+ case "agent.paused":
155
+ case "agent.blocked":
156
+ case "agent.pause.request":
157
+ return "attention";
158
+ default:
159
+ return content.kind.startsWith("agent.attention.") && !content.kind.endsWith(".resolved") ? "attention" : "info";
160
+ }
161
+ }
162
+
163
+ export {
164
+ CHANNEL_EVENT_TYPE,
165
+ decodeChannelEvent,
166
+ formatChannelEventLine,
167
+ encodeChannelEvent,
168
+ memberRenameContent,
169
+ statusFor
170
+ };
@@ -1,7 +1,7 @@
1
1
  import { createRequire as __khalaCreateRequire } from 'node:module'; const require = __khalaCreateRequire(import.meta.url);
2
2
 
3
3
  // <define:__KHALA_BUNDLE__>
4
- var define_KHALA_BUNDLE_default = { name: "khala-cli", version: "0.4.2" };
4
+ var define_KHALA_BUNDLE_default = { name: "khala-cli", version: "0.4.5" };
5
5
 
6
6
  export {
7
7
  define_KHALA_BUNDLE_default