@inetafrica/open-claudia 3.5.1 → 3.5.4

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,19 +4,29 @@
4
4
  // "messages since I last engaged" delta and advances it on every engage, so
5
5
  // group chatter the gate stayed silent through is caught up exactly once.
6
6
  // Parent-process only; small, atomic writes.
7
+ //
8
+ // Chat threads ("reply in thread") keep their own small record, keyed by the
9
+ // thread's root message id: whether the bot has replied in it (a group thread
10
+ // the bot is part of engages it without an @-mention), who posted the root
11
+ // (fetched once, so the gate never asks twice), and the thread's own context
12
+ // watermark. Bounded: the least recently touched threads are dropped first.
7
13
 
8
14
  const path = require("path");
9
15
  const { CONFIG_DIR } = require("../../core/config");
10
16
  const { atomicWriteFileSync, readJsonWithFallback } = require("../../core/fsutil");
11
17
 
12
18
  const STATE_FILE = path.join(CONFIG_DIR, "kazee-state.json");
19
+ const MAX_THREADS = 2000;
13
20
 
14
21
  let store = null;
15
22
 
16
23
  function load() {
17
24
  if (store) return store;
18
25
  const raw = readJsonWithFallback(STATE_FILE, null) || {};
19
- store = { chats: raw.chats && typeof raw.chats === "object" ? raw.chats : {} };
26
+ store = {
27
+ chats: raw.chats && typeof raw.chats === "object" ? raw.chats : {},
28
+ threads: raw.threads && typeof raw.threads === "object" && !Array.isArray(raw.threads) ? raw.threads : {},
29
+ };
20
30
  return store;
21
31
  }
22
32
 
@@ -39,4 +49,70 @@ function getContextWatermark(chatId) {
39
49
  return (c && c.contextAt) || null;
40
50
  }
41
51
 
42
- module.exports = { setContextWatermark, getContextWatermark, STATE_FILE };
52
+ function getThread(rootId) {
53
+ const s = load();
54
+ const t = s.threads[String(rootId || "")];
55
+ return t && typeof t === "object" ? t : null;
56
+ }
57
+
58
+ // Merge `patch` into the thread's record. Writes only when something changed,
59
+ // so the per-send "the bot replied here" mark costs one write per thread.
60
+ function updateThread(rootId, patch = {}) {
61
+ const id = String(rootId || "");
62
+ if (!id) return null;
63
+ const s = load();
64
+ const prev = s.threads[id] || {};
65
+ const changed = Object.keys(patch).some((k) => patch[k] !== undefined && prev[k] !== patch[k]);
66
+ if (!changed) return prev;
67
+ const next = { ...prev };
68
+ for (const [k, v] of Object.entries(patch)) if (v !== undefined) next[k] = v;
69
+ next.at = Date.now();
70
+ s.threads[id] = next;
71
+ const ids = Object.keys(s.threads);
72
+ if (ids.length > MAX_THREADS) {
73
+ ids.sort((a, b) => (Number(s.threads[a].at) || 0) - (Number(s.threads[b].at) || 0));
74
+ for (const old of ids.slice(0, ids.length - MAX_THREADS)) delete s.threads[old];
75
+ }
76
+ save();
77
+ return next;
78
+ }
79
+
80
+ function markBotRepliedInThread(chatId, rootId) {
81
+ return updateThread(rootId, { chatId: String(chatId || ""), botReplied: true });
82
+ }
83
+
84
+ function botRepliedInThread(rootId) {
85
+ return getThread(rootId)?.botReplied === true;
86
+ }
87
+
88
+ function setThreadRootAuthor(chatId, rootId, authorId) {
89
+ if (!authorId) return null;
90
+ return updateThread(rootId, { chatId: String(chatId || ""), rootAuthor: String(authorId) });
91
+ }
92
+
93
+ function getThreadRootAuthor(rootId) {
94
+ return getThread(rootId)?.rootAuthor || null;
95
+ }
96
+
97
+ function setThreadWatermark(chatId, rootId, iso) {
98
+ if (!iso) return null;
99
+ return updateThread(rootId, { chatId: String(chatId || ""), contextAt: String(iso) });
100
+ }
101
+
102
+ function getThreadWatermark(rootId) {
103
+ return getThread(rootId)?.contextAt || null;
104
+ }
105
+
106
+ module.exports = {
107
+ setContextWatermark,
108
+ getContextWatermark,
109
+ getThread,
110
+ markBotRepliedInThread,
111
+ botRepliedInThread,
112
+ setThreadRootAuthor,
113
+ getThreadRootAuthor,
114
+ setThreadWatermark,
115
+ getThreadWatermark,
116
+ STATE_FILE,
117
+ MAX_THREADS,
118
+ };
package/channels/types.js CHANGED
@@ -19,7 +19,10 @@
19
19
  * @property {string} type // "text" | "voice" | "audio" | "photo" | "document" | "action"
20
20
  * @property {string} [text] // command text or message body
21
21
  * @property {string} [messageId] // outer-channel message id
22
- * @property {string} [replyToId] // id of message being replied to
22
+ * @property {string} [replyToId] // id of message being replied to (a quote, not a thread)
23
+ * @property {string} [threadId] // chat thread root id when the message was posted IN a thread
24
+ * // (Kazee "reply in thread"); null/absent = the main timeline.
25
+ * // A thread is its own conversation: own session, queue and lane.
23
26
  * @property {Object} [reply] // {text, media?: MediaRef[], document, photo} for reply context
24
27
  * @property {Array<MediaRef>} [media]
25
28
  * @property {Object} [from] // {id, name, username}
@@ -38,6 +41,8 @@
38
41
  * // (telegram-native, accepted by both adapters)
39
42
  * // or {buttons:[PortableButton]} (portable)
40
43
  * @property {string|number} [replyTo]
44
+ * @property {string} [threadId] // post into this chat thread (Kazee threadRootId); core/io
45
+ * // fills it from the turn's thread unless set explicitly
41
46
  *
42
47
  * @typedef {Object} CommandSpec
43
48
  * @property {string} name // no leading slash
@@ -53,10 +58,11 @@
53
58
  * send(channelId, text, opts): Promise<messageId|null>
54
59
  * edit(channelId, messageId, text, opts): Promise<void>
55
60
  * delete(channelId, messageId): Promise<void>
56
- * sendVoice(channelId, oggPath): Promise<boolean>
57
- * sendFile(channelId, filePath, caption?): Promise<boolean>
58
- * typing(channelId, state?): Promise<void> // state: "thinking" | "typing" (default). Non-socket channels ignore state.
59
- * typingStop?(channelId): Promise<void> // optional — socket channels only
61
+ * sendVoice(channelId, oggPath, opts?): Promise<boolean> // opts.threadId: post into that thread
62
+ * sendFile(channelId, filePath, caption?, opts?): Promise<boolean> // opts.threadId: post into that thread
63
+ * typing(channelId, state?, opts?): Promise<void> // state: "thinking" | "typing" (default). Non-socket channels ignore state.
64
+ * // opts.threadId: show the indicator inside that thread
65
+ * typingStop?(channelId, opts?): Promise<void> // optional — socket channels only
60
66
  * downloadMedia(mediaRef): Promise<localPath|null>
61
67
  * buildThreadContext?(envelope): Promise<{contextBlock: string, media?: MediaRef[]}|null>
62
68
  * registerCommands(commands: CommandSpec[]): Promise<void>
package/core/actions.js CHANGED
@@ -622,6 +622,7 @@ async function handleAction(envelope) {
622
622
  adapter: "",
623
623
  adapterType: rec.adapter,
624
624
  channelId: String(rec.channelId),
625
+ threadId: rec.threadId || null,
625
626
  canonicalUserId: rec.canonicalUserId,
626
627
  userId: rec.userId || null,
627
628
  provider: rec.provider,
@@ -692,6 +693,9 @@ async function handleAction(envelope) {
692
693
  adapter: isExternalAction ? "" : (rec.adapter || (adapter && adapter.id) || ""),
693
694
  adapterType: isExternalAction ? (rec.adapter || (adapter && adapter.type)) : (adapter && adapter.type),
694
695
  channelId: String(rec.channelId || envelope.channelId),
696
+ // Back into the thread the held run was in (the press itself may
697
+ // come from another room: the owner's approver channel).
698
+ threadId: rec.threadId || (rec.channelId ? null : envelope.threadId || null),
695
699
  canonicalUserId: rec.canonicalUserId,
696
700
  userId: rec.userId || null,
697
701
  provider: rec.provider,
@@ -991,6 +995,8 @@ async function handleAction(envelope) {
991
995
  adapter: envelope.adapter.id,
992
996
  adapterType: envelope.adapter.type,
993
997
  channelId: String(envelope.channelId),
998
+ // A preset picked in a thread fires in that thread.
999
+ threadId: envelope.threadId || null,
994
1000
  canonicalUserId: envelope.canonicalUserId,
995
1001
  project: cronProject,
996
1002
  prompt: p.prompt,
package/core/approvals.js CHANGED
@@ -19,6 +19,7 @@ const path = require("path");
19
19
  const crypto = require("crypto");
20
20
  const CONFIG_DIR = require("../config-dir");
21
21
  const { atomicWriteFileSync } = require("./fsutil");
22
+ const { currentChannelId, currentThreadId } = require("./context");
22
23
 
23
24
  const APPROVALS_DIR = process.env.APPROVALS_DIR || path.join(CONFIG_DIR, "approvals");
24
25
  const MAX_AGE_MS = 24 * 60 * 60 * 1000;
@@ -124,17 +125,25 @@ function create({
124
125
  // room holds many people they are different values, so recording only the
125
126
  // room would hand one member's grant to everyone in it.
126
127
  speakerId = "", room = "",
128
+ // The chat thread of the ORIGIN turn (null = main timeline), so a re-run
129
+ // this approval wakes lands back in that thread. Unset: taken from the
130
+ // current turn when it is in the origin channel.
131
+ threadId = undefined,
127
132
  } = {}) {
128
133
  fs.mkdirSync(APPROVALS_DIR, { recursive: true, mode: 0o700 });
129
134
  prune();
130
135
  const id = `apr_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`;
131
136
  const exactCommand = approvedCommand(command);
137
+ const originThread = threadId !== undefined
138
+ ? threadId
139
+ : (currentThreadId() && String(currentChannelId() || "") === String(channelId || "") ? currentThreadId() : null);
132
140
  const rec = {
133
141
  id, status: "pending",
134
142
  tool: String(tool || ""), verb: String(verb || ""), tier: String(tier || ""),
135
143
  command: exactCommand,
136
144
  commandHash: commandHash(exactCommand),
137
145
  channelId: String(channelId || ""), adapter: String(adapter || ""),
146
+ threadId: originThread ? String(originThread) : null,
138
147
  canonicalUserId: String(canonicalUserId || ""), userId: String(userId || ""),
139
148
  provider: String(provider || ""), providerSettings: providerSettings && typeof providerSettings === "object" ? structuredClone(providerSettings) : {},
140
149
  project: String(project || ""), projectDir: String(projectDir || ""),
package/core/context.js CHANGED
@@ -6,8 +6,8 @@ const { AsyncLocalStorage } = require("node:async_hooks");
6
6
 
7
7
  const chatContext = new AsyncLocalStorage();
8
8
 
9
- function runInChat({ adapter, channelId, canonicalUserId, userId, transport, raw, conversationKey, roomKey, inboundText, speakerName, triageApprovalId, isGroup }, fn) {
10
- return chatContext.run({ adapter, channelId, canonicalUserId, userId, transport, raw, conversationKey, roomKey, inboundText, speakerName, triageApprovalId, isGroup: !!isGroup }, fn);
9
+ function runInChat({ adapter, channelId, threadId, canonicalUserId, userId, transport, raw, conversationKey, roomKey, inboundText, speakerName, triageApprovalId, isGroup }, fn) {
10
+ return chatContext.run({ adapter, channelId, threadId: threadId ? String(threadId) : null, canonicalUserId, userId, transport, raw, conversationKey, roomKey, inboundText, speakerName, triageApprovalId, isGroup: !!isGroup }, fn);
11
11
  }
12
12
 
13
13
  function currentStore() {
@@ -24,6 +24,14 @@ function currentChannelId() {
24
24
  return s ? s.channelId : null;
25
25
  }
26
26
 
27
+ // The chat thread this turn belongs to (a Kazee thread's root message id), or
28
+ // null for the channel's main timeline. A thread is its own conversation: its
29
+ // own session, transcript, queue and run lane, and every reply goes back into it.
30
+ function currentThreadId() {
31
+ const s = currentStore();
32
+ return s && s.threadId ? s.threadId : null;
33
+ }
34
+
27
35
  function currentCanonicalUserId() {
28
36
  const s = currentStore();
29
37
  return s ? s.canonicalUserId : null;
@@ -114,6 +122,7 @@ module.exports = {
114
122
  runInChat,
115
123
  currentAdapter,
116
124
  currentChannelId,
125
+ currentThreadId,
117
126
  currentCanonicalUserId,
118
127
  currentUserId,
119
128
  currentTransport,
package/core/handlers.js CHANGED
@@ -20,8 +20,10 @@ const {
20
20
  getProviderSettings, resolveProviderSettings,
21
21
  getProjectSessions, getLastProjectSession, userOwnsProviderSession,
22
22
  createSessionCallbackToken, linkIdentity,
23
- roomSettingsFor, setRoomSetting, cancelFlagFor,
23
+ roomSettingsFor, setRoomSetting, cancelFlagFor, activeRoomKey,
24
+ laneFor, peekLane, runningLanes, laneBusy, currentLaneKey,
24
25
  } = require("./state");
26
+ const { parseConversationKey } = require("./identity");
25
27
  const { canonicalForChannel, canonicalForTelegram, normalizeCanonicalUserId, channelKey, identities, isConfiguredOwnerChannel, removeIdentityMapping } = require("./identity");
26
28
  const { isChatAuthorized, isChatOwner, recordPendingAuthRequest, authRequestLabel, hasOwner, bootstrapOwner } = require("./access");
27
29
  const { isOnboarded, startOnboarding } = require("./onboarding");
@@ -85,12 +87,25 @@ function cancelledQueueResult(item, reason) {
85
87
  };
86
88
  }
87
89
 
88
- function clearTransientQueue(state, reason = "Session context changed before the queued run started") {
89
- const queued = Array.isArray(state?.messageQueue) ? state.messageQueue.splice(0) : [];
90
+ // Drops every message queued in one lane: each one's caller gets a cancelled
91
+ // result (no hanging promise), and an idle lane loses its typing heartbeat (the
92
+ // chip would stay on, and the lane could never be pruned). Returns the items.
93
+ function dropLaneQueue(lane, reason) {
94
+ if (!lane) return [];
95
+ const queued = lane.messageQueue.splice(0);
96
+ lane.waitingForSlot = false;
90
97
  for (const item of queued) {
98
+ try { item?.disposeCallListener?.(); } catch (_) {}
91
99
  try { item?.deferred?.resolve(cancelledQueueResult(item, reason)); } catch (_) {}
92
100
  }
93
- return queued.length;
101
+ if (!laneBusy(lane)) require("./runner").stopLaneTyping(lane);
102
+ return queued;
103
+ }
104
+
105
+ // Clears the CURRENT conversation's queue only: /new, /end or a project change
106
+ // in one thread must not drop what another conversation has waiting.
107
+ function clearTransientQueue(state, reason = "Session context changed before the queued run started") {
108
+ return dropLaneQueue(state ? peekLane(state) : null, reason).length;
94
109
  }
95
110
 
96
111
  function persistSessionControl(state, options, rollback) {
@@ -216,7 +231,7 @@ function switchProviderState(providerId, options = {}) {
216
231
  // stays updated as their default for rooms they have never pinned. Without
217
232
  // the room write, switching provider only moved the switcher — everyone else
218
233
  // in the room, and every job it spawned, stayed on the old one.
219
- const roomKey = state.roomKey ? String(state.roomKey) : "";
234
+ const roomKey = activeRoomKey() || "";
220
235
  const previousRoomBackend = roomKey ? (roomSettingsFor(roomKey)?.backend ?? null) : null;
221
236
  if (roomKey) setRoomSetting(roomKey, "backend", provider);
222
237
  state.settings.backend = provider;
@@ -297,7 +312,8 @@ function endCurrentSession(options = {}) {
297
312
  clearTransientQueue(state, "The conversation ended before the queued run started");
298
313
  state.currentSession = rootSession();
299
314
  state.isFirstMessage = true;
300
- state.cancelRequested = false;
315
+ const lane = peekLane(state);
316
+ if (lane) lane.cancelRequested = false;
301
317
  const persisted = persistSessionControl(state, options, () => {
302
318
  state.currentSession = previous.currentSession;
303
319
  state.isFirstMessage = previous.isFirstMessage;
@@ -1413,10 +1429,10 @@ register({
1413
1429
  if (arg) {
1414
1430
  const low = arg.toLowerCase();
1415
1431
  if (low === "clone") {
1416
- state.pendingVoiceClone = { at: Date.now() };
1432
+ laneFor(state).pendingVoiceClone = { at: Date.now() };
1417
1433
  return send("🎙️ Send me a voice note now — 5–15s of clear speech and I'll clone it as your voice. Send /voice to cancel.");
1418
1434
  }
1419
- state.pendingVoiceClone = null;
1435
+ laneFor(state).pendingVoiceClone = null;
1420
1436
  if (low === "default" || low === "reset") {
1421
1437
  ttsVoice.clearVoice();
1422
1438
  return send(`🎙️ Voice reset to default (${ttsVoice.getVoiceLabel()}).`);
@@ -1431,7 +1447,7 @@ register({
1431
1447
  ttsVoice.setVoice(arg, null);
1432
1448
  return send(`🎙️ Voice set: ${arg}`);
1433
1449
  }
1434
- state.pendingVoiceClone = null;
1450
+ laneFor(state).pendingVoiceClone = null;
1435
1451
  const rows = ttsVoice.presets().map((p) => [{ text: p.label, callback_data: `vc:p:${p.key}` }]);
1436
1452
  rows.push([
1437
1453
  { text: "🎙️ Clone from a clip", callback_data: "vc:clone" },
@@ -1643,63 +1659,148 @@ register({
1643
1659
  });
1644
1660
 
1645
1661
  // How long /stop waits for a stopped child to report its exit before it
1646
- // releases the room anyway (SIGKILL follows SIGTERM after 3 s).
1662
+ // releases the conversation anyway (SIGKILL follows SIGTERM after 3 s).
1647
1663
  const STOP_RELEASE_MS = 30 * 1000;
1648
- function releaseStuckStop(state, proc, stopped) {
1649
- if (state.runningProcess !== proc) return;
1650
- if (proc.exitCode != null || proc.signalCode != null) return; // exited: its unwind releases the room
1651
- console.warn(`[stop] child ${proc.pid} never reported its exit; releasing the room`);
1652
- state.runningProcess = null;
1653
- if (state.cancelRequested === stopped) state.cancelRequested = false;
1664
+ function releaseStuckStop(state, lane, proc, stopped) {
1665
+ if (lane.runningProcess !== proc) return;
1666
+ if (proc.exitCode != null || proc.signalCode != null) return; // exited: its unwind releases the lane
1667
+ console.warn(`[stop] child ${proc.pid} never reported its exit; releasing the conversation`);
1668
+ lane.runningProcess = null;
1669
+ lane.ownerToken = null;
1670
+ if (lane.cancelRequested === stopped) lane.cancelRequested = false;
1671
+ try { require("./runner").drainQueuedMessages(state); } catch (_) {}
1672
+ }
1673
+
1674
+ // Stop one lane's turn. Same kill semantics as a single /stop always had:
1675
+ // SIGTERM the tree now, SIGKILL after 3 s, keep the lane busy until the run
1676
+ // unwinds (its own finally frees it and admits what waits). Returns what was
1677
+ // stopped: "running", "preparing", or null when the lane had nothing to stop.
1678
+ function stopLane(state, lane) {
1679
+ const { stopLaneTyping } = require("./runner");
1680
+ if (lane.runningProcess && !lane.activeRunContext) {
1681
+ // Only a compaction runs here (a /compact, or the auto-compaction tail of
1682
+ // a turn that already answered). Kill it and let it fail on its own; a
1683
+ // bare cancel flag would outlive it and silently eat the next message.
1684
+ const pid = lane.runningProcess.pid;
1685
+ const { killProcessTree } = require("./process-tree");
1686
+ killProcessTree(pid, "SIGTERM");
1687
+ setTimeout(() => killProcessTree(pid, "SIGKILL"), 3000);
1688
+ stopLaneTyping(lane);
1689
+ return "running";
1690
+ }
1691
+ if (lane.runningProcess) {
1692
+ const proc = lane.runningProcess;
1693
+ const pid = proc.pid;
1694
+ const { killProcessTree } = require("./process-tree");
1695
+ // Quiet unwind: deliverForegroundResult consumes this flag to suppress
1696
+ // the "run failed" diagnostic the SIGTERM exit would otherwise emit. It
1697
+ // names the run it stops, so it can never cancel a different one.
1698
+ const stopped = cancelFlagFor(lane);
1699
+ lane.cancelRequested = stopped;
1700
+ killProcessTree(pid, "SIGTERM");
1701
+ setTimeout(() => killProcessTree(pid, "SIGKILL"), 3000);
1702
+ // The lane stays busy until the stopped run has unwound: its own finally
1703
+ // releases it, clears this flag and admits the queue. Releasing it here
1704
+ // let a message sent in those seconds start, meet the flag and be
1705
+ // cancelled "before the provider started" (24 Sep: three messages lost
1706
+ // after one /stop). Backstop: a child that never reports its exit must
1707
+ // not pin the conversation.
1708
+ setTimeout(() => releaseStuckStop(state, lane, proc, stopped), STOP_RELEASE_MS).unref?.();
1709
+ // Stop pending preview flushes but keep statusMessageId/Channel: the
1710
+ // killed run's delivery unwind edits the preview to drop its footer.
1711
+ if (lane.streamInterval) { clearTimeout(lane.streamInterval); lane.streamInterval = null; }
1712
+ // Clearing the heartbeat here orphans the run's own stopper (ownership
1713
+ // check makes it a no-op), so the explicit typing:stop must go out now.
1714
+ stopLaneTyping(lane);
1715
+ return "running";
1716
+ }
1717
+ if (lane.preparingRun) {
1718
+ // Turn is mid-recall/prompt-build — no process to kill yet. Flag it so
1719
+ // the runner's pre-spawn shouldAbort checkpoint bails before spawning.
1720
+ lane.cancelRequested = cancelFlagFor(lane);
1721
+ stopLaneTyping(lane);
1722
+ return "preparing";
1723
+ }
1724
+ return null;
1725
+ }
1726
+
1727
+ const STOP_DROP_REASON = "Dropped by /stop before it started";
1728
+
1729
+ // "Dropped 2 queued: "reboot pe2", "and check the…"": what /stop dropped, a
1730
+ // few words of each message (the first few in full, then a count).
1731
+ function droppedNote(items, kind) {
1732
+ const words = (item) => {
1733
+ const all = String(item?.text || item?.runContext?.userPrompt || item?.pendingSideChat?.originalRunContext?.userPrompt || "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
1734
+ const head = all.slice(0, 3).join(" ");
1735
+ const short = head.length > 24 ? `${head.slice(0, 23)}…` : (all.length > 3 ? `${head}…` : head);
1736
+ return `"${short || "…"}"`;
1737
+ };
1738
+ const n = items.length;
1739
+ const shown = items.slice(0, 3).map(words);
1740
+ if (n > shown.length) shown.push(`+${n - shown.length} more`);
1741
+ const label = kind === "waiting"
1742
+ ? `${n} waiting message${n === 1 ? "" : "s"}`
1743
+ : `${n} queued`;
1744
+ return `Dropped ${label}: ${shown.join(", ")}`;
1745
+ }
1746
+
1747
+ // A few words about a conversation: main timeline or which thread, and what
1748
+ // its turn is about.
1749
+ function describeLane(lane, currentRoom = null) {
1750
+ const { threadId, roomKey } = parseConversationKey(lane.key);
1751
+ const where = `${threadId ? `thread …${threadId.slice(-6)}` : "main"}${currentRoom && roomKey !== currentRoom ? ` (${roomKey})` : ""}`;
1752
+ const words = String(lane.activeRunContext?.userPrompt || "")
1753
+ .replace(/\s+/g, " ").trim().split(" ").slice(0, 6).join(" ");
1754
+ const what = lane.isCompacting ? "compacting" : (words ? `"${words.length > 48 ? `${words.slice(0, 47)}…` : words}"` : "working");
1755
+ return `${where}: ${what}`;
1756
+ }
1757
+
1758
+ function runningConversationLines(state) {
1759
+ const lanes = runningLanes(state);
1760
+ const waiting = state.lanes instanceof Map
1761
+ ? [...state.lanes.values()].filter((lane) => !laneBusy(lane) && lane.messageQueue.length).length
1762
+ : 0;
1763
+ if (!lanes.length && !waiting) return [];
1764
+ return [
1765
+ `Running conversations: ${lanes.length}${waiting ? ` (${waiting} waiting for a slot)` : ""}`,
1766
+ ...lanes.map((lane) => `- ${describeLane(lane, parseConversationKey(currentLaneKey(state)).roomKey)}`),
1767
+ ];
1654
1768
  }
1655
1769
 
1656
1770
  register({
1657
- name: "stop", description: "Cancel running task",
1658
- handler: async (env) => {
1771
+ name: "stop", description: "Cancel the running task here (/stop all: every conversation)", args: "[all]",
1772
+ handler: async (env, { tail } = {}) => {
1659
1773
  if (!authorized(env)) return;
1660
1774
  const state = currentState();
1661
- const sideCancellation = require("./side-chat").sideChatCoordinator.cancel(state.userId);
1662
- // /stop cancels the CURRENT turn only. Messages queued behind it stay
1663
- // queued — the cancelled run's unwind drains them as usual.
1664
- const queuedNote = () => {
1665
- const n = Array.isArray(state.messageQueue) ? state.messageQueue.length : 0;
1666
- return n ? ` ${n} queued message${n === 1 ? "" : "s"} will run next.` : "";
1667
- };
1668
- if (state.runningProcess) {
1669
- const proc = state.runningProcess;
1670
- const pid = proc.pid;
1671
- const { killProcessTree } = require("./process-tree");
1672
- // Quiet unwind: deliverForegroundResult consumes this flag to suppress
1673
- // the "run failed" diagnostic the SIGTERM exit would otherwise emit. It
1674
- // names the run it stops, so it can never cancel a different one.
1675
- const stopped = cancelFlagFor(state);
1676
- state.cancelRequested = stopped;
1677
- killProcessTree(pid, "SIGTERM");
1678
- setTimeout(() => killProcessTree(pid, "SIGKILL"), 3000);
1679
- // The room stays busy until the stopped run has unwound: its own
1680
- // finally releases the lock, clears this flag and drains the queue.
1681
- // Releasing it here let a message sent in those seconds start, meet the
1682
- // flag and be cancelled "before the provider started" (24 Sep: three
1683
- // messages lost after one /stop). Backstop: a child that never reports
1684
- // its exit must not pin the room.
1685
- setTimeout(() => releaseStuckStop(state, proc, stopped), STOP_RELEASE_MS).unref?.();
1686
- // Stop pending preview flushes but keep statusMessageId/Channel: the
1687
- // killed run's delivery unwind edits the preview to drop its footer.
1688
- if (state.streamInterval) { clearTimeout(state.streamInterval); state.streamInterval = null; }
1689
- // Clearing the interval here orphans the run's own stopper (ownership
1690
- // check makes it a no-op), so the explicit typing:stop must go out now.
1691
- if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; state.thinkingPhase = false; state.typingTick = null; }
1692
- try { currentAdapter()?.typingStop?.(currentChannelId()); } catch (_) {}
1775
+ const { sideChatCoordinator } = require("./side-chat");
1776
+ if (String(tail || "").trim().toLowerCase() === "all") {
1777
+ const sideCancellation = sideChatCoordinator.cancel(state.userId);
1778
+ const lanes = state.lanes instanceof Map ? [...state.lanes.values()] : [];
1779
+ // Drop the queues first, so no stopped lane's unwind admits them.
1780
+ const dropped = lanes.flatMap((lane) => dropLaneQueue(lane, STOP_DROP_REASON));
1781
+ const stopped = lanes.filter((lane) => stopLane(state, lane)).length;
1782
+ const sideStopped = await sideCancellation;
1783
+ if (!stopped && !dropped.length) return send(sideStopped ? "Cancelled side response." : "Nothing running.");
1784
+ const parts = [];
1785
+ if (stopped) parts.push(`Cancelled ${stopped} running conversation${stopped === 1 ? "" : "s"}.`);
1786
+ if (dropped.length) parts.push(droppedNote(dropped, stopped ? "queued" : "waiting"));
1787
+ return send(parts.join(" "));
1788
+ }
1789
+ // /stop cancels the CURRENT conversation's turn only (other threads keep
1790
+ // running) and drops what was queued in it before the /stop: the person
1791
+ // meant "stop", not "stop this one and start the next". A message sent
1792
+ // after the /stop still queues behind the unwinding run and then runs.
1793
+ const lane = laneFor(state);
1794
+ const sideCancellation = sideChatCoordinator.cancel(state.userId, lane.key);
1795
+ const dropped = dropLaneQueue(lane, STOP_DROP_REASON);
1796
+ if (stopLane(state, lane)) {
1693
1797
  await sideCancellation;
1694
- await send(`Cancelled.${queuedNote()}`);
1695
- } else if (state.preparingRun) {
1696
- // Turn is mid-recall/prompt-build — no process to kill yet. Flag it so
1697
- // the runner's pre-spawn shouldAbort checkpoint bails before spawning.
1698
- state.cancelRequested = cancelFlagFor(state);
1699
- if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; state.thinkingPhase = false; state.typingTick = null; }
1700
- try { currentAdapter()?.typingStop?.(currentChannelId()); } catch (_) {}
1798
+ await send(dropped.length ? `Cancelled. ${droppedNote(dropped, "queued")}` : "Cancelled.");
1799
+ } else if (dropped.length) {
1800
+ // Nothing to cancel: the dropped messages were waiting for a slot (or
1801
+ // queued behind a turn that is only finishing its tail).
1701
1802
  await sideCancellation;
1702
- await send(`Cancelled.${queuedNote()}`);
1803
+ await send(droppedNote(dropped, laneBusy(lane) ? "queued" : "waiting"));
1703
1804
  } else if (await sideCancellation) await send("Cancelled side response.");
1704
1805
  else await send("Nothing running.");
1705
1806
  },
@@ -1741,7 +1842,8 @@ register({
1741
1842
  `Recall engine: ${activeEngine}`,
1742
1843
  `Auto-compact: ${formatCompactWindow(state)}`,
1743
1844
  `Vault: ${vault.isUnlocked() ? "unlocked" : "locked"} | Crons: ${cronCount}`,
1744
- state.runningProcess ? "Working..." : "Ready.",
1845
+ laneBusy(peekLane(state)) ? "Working..." : "Ready.",
1846
+ ...runningConversationLines(state),
1745
1847
  ].join("\n"));
1746
1848
  },
1747
1849
  });
package/core/identity.js CHANGED
@@ -18,14 +18,42 @@ function defaultCanonicalForChannel(transport, channelId) {
18
18
  return channelKey(transport, channelId);
19
19
  }
20
20
 
21
- // The conversation (thread) key for a channel. Deliberately identical to the
22
- // pre-unification default canonical: a thread is keyed by its raw channel
21
+ // The conversation key for a channel. Deliberately identical to the
22
+ // pre-unification default canonical: a conversation is keyed by its raw channel
23
23
  // identity, never by the unified speaker canonical. This is what keeps each
24
24
  // surface's live conversation separate even when the speaker is one being.
25
- function conversationKeyFor(transport, channelId) {
25
+ // A chat THREAD (Kazee "reply in thread") is a conversation of its own inside
26
+ // the channel: `<transport>:<channelId>#thread:<rootId>`. The channel's main
27
+ // timeline keeps the bare channel key, so nothing existing changes. The room
28
+ // (settings, grants) stays the channel key: use roomKeyFor for that.
29
+ const THREAD_SEP = "#thread:";
30
+ function conversationKeyFor(transport, channelId, threadId) {
31
+ const base = channelKey(transport, channelId);
32
+ const t = String(threadId || "").trim();
33
+ return t ? `${base}${THREAD_SEP}${t}` : base;
34
+ }
35
+
36
+ function roomKeyFor(transport, channelId) {
26
37
  return channelKey(transport, channelId);
27
38
  }
28
39
 
40
+ // Split a conversation key back into its parts. The channel id may itself
41
+ // contain ':' (e.g. spaces "task:42"), so only the first ':' separates the
42
+ // transport. Returns {transport, channelId, threadId|null, roomKey}.
43
+ function parseConversationKey(key) {
44
+ const k = String(key || "");
45
+ const i = k.indexOf(THREAD_SEP);
46
+ const room = i >= 0 ? k.slice(0, i) : k;
47
+ const threadId = i >= 0 ? k.slice(i + THREAD_SEP.length) || null : null;
48
+ const c = room.indexOf(":");
49
+ return {
50
+ transport: c >= 0 ? room.slice(0, c) : room,
51
+ channelId: c >= 0 ? room.slice(c + 1) : "",
52
+ threadId,
53
+ roomKey: room,
54
+ };
55
+ }
56
+
29
57
  function loadIdentities() {
30
58
  const raw = readJsonWithFallback(IDENTITIES_FILE, null);
31
59
  return {
@@ -196,6 +224,8 @@ module.exports = {
196
224
  channelKey,
197
225
  defaultCanonicalForChannel,
198
226
  conversationKeyFor,
227
+ roomKeyFor,
228
+ parseConversationKey,
199
229
  isConfiguredOwnerChannel,
200
230
  ownerCanonical,
201
231
  channelIsOwner,