@inetafrica/open-claudia 3.5.0 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +32 -2
  2. package/bin/cli.js +13 -5
  3. package/bin/loopback-client.js +23 -5
  4. package/bin/owner-gate.js +2 -1
  5. package/bin/schedule.js +7 -2
  6. package/bin/tool.js +2 -2
  7. package/bot.js +6 -2
  8. package/channels/kazee/adapter.js +462 -64
  9. package/channels/kazee/state.js +78 -2
  10. package/channels/types.js +11 -5
  11. package/core/actions.js +6 -0
  12. package/core/approvals.js +9 -0
  13. package/core/bot-endpoint.js +194 -0
  14. package/core/context.js +11 -2
  15. package/core/handlers.js +161 -59
  16. package/core/identity.js +33 -3
  17. package/core/io.js +21 -8
  18. package/core/jobs.js +3 -0
  19. package/core/loopback.js +55 -7
  20. package/core/providers/claude.js +6 -2
  21. package/core/router.js +25 -13
  22. package/core/run-context.js +49 -8
  23. package/core/runner.js +313 -126
  24. package/core/scheduler.js +64 -32
  25. package/core/side-chat.js +40 -11
  26. package/core/speaker-attest.js +2 -0
  27. package/core/state.js +317 -50
  28. package/core/system-prompt.js +61 -14
  29. package/core/tool-guard.js +15 -0
  30. package/core/transcripts.js +23 -6
  31. package/package.json +3 -3
  32. package/test-approval-cards.js +24 -0
  33. package/test-call-speech.js +5 -3
  34. package/test-fixtures/fake-agent-cli.js +52 -1
  35. package/test-provider-compaction.js +50 -11
  36. package/test-provider-gateway.js +31 -10
  37. package/test-provider-language.js +15 -1
  38. package/test-provider-parity-e2e.js +8 -2
  39. package/test-provider-scheduler.js +77 -20
  40. package/test-provider-session-commands.js +11 -2
  41. package/test-provider-side-chat.js +30 -2
  42. package/test-run-lock.js +122 -39
  43. package/test-thread-lanes.js +530 -0
  44. package/core/queue-drain.js +0 -25
  45. package/test-queue-routing.js +0 -52
@@ -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 || ""),
@@ -0,0 +1,194 @@
1
+ // Which loopback endpoint may a CLI believe about WHO it acts for?
2
+ //
3
+ // Owner-only CLI verbs and staff tool writes are decided by asking the bot
4
+ // (whoami, tool-gate). The address to ask comes from this process's env
5
+ // (OC_SEND_URL), and a staff turn's shell can point that at a server of its
6
+ // own that answers "owner" (Phase 0 review, CRITICAL). So an endpoint is
7
+ // believed for those answers only when:
8
+ // 1. the process LISTENING on it is this process or one of its ancestors —
9
+ // the bot spawns every run, so the real bot is always above the CLI,
10
+ // while a server a turn starts is beside it at best; and
11
+ // 2. nothing above that listener was STARTED with OC_GUARDED_RUN=1 in its
12
+ // environment — the listener was not itself started inside a staff turn
13
+ // (such a server could run the CLI as its own child, making itself an
14
+ // ancestor). A process's start-up environment is fixed by whoever spawned
15
+ // it; a turn's shell cannot rewrite its provider's.
16
+ // Linux reads /proc; macOS asks /bin/ps and /usr/sbin/lsof by absolute path
17
+ // (PATH belongs to the caller). Anything that cannot be established is a
18
+ // refusal, and a refused caller is unattributed — never the owner.
19
+ //
20
+ // Residual, closed by Phase 2's tool-only staff turns: a staff shell that
21
+ // detaches a server from its turn and runs the CLI beneath that server is no
22
+ // longer below the guarded provider, so rule 2 cannot see it.
23
+
24
+ const fs = require("fs");
25
+ const { execFileSync } = require("child_process");
26
+
27
+ const MAX_DEPTH = 64;
28
+ const PS = "/bin/ps";
29
+ const LSOF = "/usr/sbin/lsof";
30
+ const EXEC_TIMEOUT_MS = 5000;
31
+
32
+ function parsePort(url) {
33
+ let parsed;
34
+ try { parsed = new URL(String(url)); } catch (_) { return null; }
35
+ if (parsed.protocol !== "http:") return null;
36
+ if (parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") return null;
37
+ const port = Number(parsed.port);
38
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
39
+ }
40
+
41
+ // ── Linux: /proc ─────────────────────────────────────────────────────────────
42
+ // The loopback binds 127.0.0.1 itself, never a wildcard, and nothing else can
43
+ // bind that exact address and port while it listens (no SO_REUSEPORT), so the
44
+ // only listener that counts is an IPv4 127.0.0.1 socket. /proc/net/tcp shows
45
+ // the address as little-endian hex.
46
+ const LINUX_LOOPBACK_HEX = "0100007F";
47
+
48
+ function linuxSystem(procRoot = "/proc") {
49
+ return {
50
+ parentOf(pid) {
51
+ const stat = fs.readFileSync(`${procRoot}/${pid}/stat`, "utf8");
52
+ // "pid (comm) state ppid ..." — comm may hold spaces and parentheses.
53
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
54
+ const ppid = Number(fields[1]);
55
+ return Number.isInteger(ppid) ? ppid : null;
56
+ },
57
+ // The pid in `pids` that owns the 127.0.0.1:port listening socket, else null.
58
+ listenerAmong(port, pids) {
59
+ const inodes = new Set();
60
+ const text = fs.readFileSync(`${procRoot}/net/tcp`, "utf8");
61
+ for (const line of text.split("\n").slice(1)) {
62
+ const cols = line.trim().split(/\s+/);
63
+ if (cols.length < 10 || cols[3] !== "0A") continue; // 0A = LISTEN
64
+ const [addr, portHex] = cols[1].split(":");
65
+ if (addr === LINUX_LOOPBACK_HEX && parseInt(portHex, 16) === port) inodes.add(cols[9]);
66
+ }
67
+ if (!inodes.size) return null;
68
+ for (const pid of pids) {
69
+ let fds = [];
70
+ try { fds = fs.readdirSync(`${procRoot}/${pid}/fd`); } catch (_) { continue; }
71
+ for (const fd of fds) {
72
+ let link = "";
73
+ try { link = fs.readlinkSync(`${procRoot}/${pid}/fd/${fd}`); } catch (_) { continue; }
74
+ const match = /^socket:\[(\d+)\]$/.exec(link);
75
+ if (match && inodes.has(match[1])) return pid;
76
+ }
77
+ }
78
+ return null;
79
+ },
80
+ startEnvHas(pid, key, value) {
81
+ const raw = fs.readFileSync(`${procRoot}/${pid}/environ`, "utf8");
82
+ return raw.split("\0").includes(`${key}=${value}`);
83
+ },
84
+ };
85
+ }
86
+
87
+ // ── macOS: /bin/ps + /usr/sbin/lsof ─────────────────────────────────────────
88
+ function run(file, args) {
89
+ return execFileSync(file, args, {
90
+ encoding: "utf8",
91
+ stdio: ["ignore", "pipe", "ignore"],
92
+ timeout: EXEC_TIMEOUT_MS,
93
+ maxBuffer: 64 * 1024 * 1024,
94
+ env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LANG: "C", LC_ALL: "C" },
95
+ });
96
+ }
97
+
98
+ function darwinSystem() {
99
+ let parents = null;
100
+ return {
101
+ parentOf(pid) {
102
+ if (!parents) {
103
+ parents = new Map();
104
+ for (const line of run(PS, ["-Ao", "pid=,ppid="]).split("\n")) {
105
+ const [p, pp] = line.trim().split(/\s+/).map(Number);
106
+ if (Number.isInteger(p) && Number.isInteger(pp)) parents.set(p, pp);
107
+ }
108
+ }
109
+ return parents.has(pid) ? parents.get(pid) : null;
110
+ },
111
+ listenerAmong(port, pids) {
112
+ let out = "";
113
+ try { out = run(LSOF, ["-nP", "-a", "-p", pids.join(","), `-iTCP@127.0.0.1:${port}`, "-sTCP:LISTEN", "-Fpn"]); }
114
+ catch (error) {
115
+ // Exit 1 whenever some listed pid matched nothing (or is another
116
+ // user's); what did match is still on stdout.
117
+ if (!error || error.status !== 1) throw error;
118
+ out = String(error.stdout || "");
119
+ }
120
+ const owners = new Set();
121
+ let current = null;
122
+ for (const line of out.split("\n")) {
123
+ if (line.startsWith("p")) current = Number(line.slice(1));
124
+ else if (line === `n127.0.0.1:${port}` && current != null) owners.add(current);
125
+ }
126
+ for (const pid of pids) if (owners.has(pid)) return pid;
127
+ return null;
128
+ },
129
+ // ps -E prints the command line followed by the environment the process
130
+ // started with; the environment is what follows the plain command line.
131
+ startEnvHas(pid, key, value) {
132
+ const withEnv = run(PS, ["-E", "-ww", "-o", "command=", "-p", String(pid)]).replace(/\n+$/, "");
133
+ const bare = run(PS, ["-ww", "-o", "command=", "-p", String(pid)]).replace(/\n+$/, "");
134
+ if (!withEnv.startsWith(bare)) return false;
135
+ return withEnv.slice(bare.length).split(" ").includes(`${key}=${value}`);
136
+ },
137
+ };
138
+ }
139
+
140
+ function currentSystem() {
141
+ if (process.platform === "linux") return linuxSystem();
142
+ if (process.platform === "darwin") return darwinSystem();
143
+ return null;
144
+ }
145
+
146
+ function ancestry(pid, system) {
147
+ const chain = [pid];
148
+ let current = pid;
149
+ while (chain.length < MAX_DEPTH) {
150
+ const parent = system.parentOf(current);
151
+ if (!Number.isInteger(parent) || parent <= 0 || chain.includes(parent)) break;
152
+ chain.push(parent);
153
+ current = parent;
154
+ }
155
+ return chain;
156
+ }
157
+
158
+ function refuse(reason) {
159
+ return { ok: false, reason };
160
+ }
161
+
162
+ // { ok: true, url, port, listenerPid } when `url` may be believed about who
163
+ // this process acts for; { ok: false, reason } otherwise.
164
+ function verifyEndpoint(url, { pid = process.pid, system = currentSystem() } = {}) {
165
+ const port = parsePort(url);
166
+ if (!port) return refuse("the loopback address is not a 127.0.0.1 endpoint");
167
+ if (!system) return refuse(`cannot inspect processes on ${process.platform}`);
168
+ let chain;
169
+ let listener;
170
+ try {
171
+ chain = ancestry(pid, system);
172
+ listener = system.listenerAmong(port, chain);
173
+ } catch (error) {
174
+ return refuse(`cannot read the process tree (${error && error.message ? error.message : error})`);
175
+ }
176
+ if (listener == null) return refuse("the loopback endpoint is not served by this process or an ancestor");
177
+ for (const above of chain.slice(chain.indexOf(listener) + 1)) {
178
+ let guarded = false;
179
+ // Unreadable = another user's process, never a guarded provider (the bot
180
+ // starts providers as its own user).
181
+ try { guarded = system.startEnvHas(above, "OC_GUARDED_RUN", "1") === true; } catch (_) { guarded = false; }
182
+ if (guarded) return refuse("the loopback endpoint was started inside a guarded (non-owner) run");
183
+ }
184
+ return { ok: true, url: `http://127.0.0.1:${port}`, port, listenerPid: listener };
185
+ }
186
+
187
+ const verified = new Map();
188
+ function verifyEndpointCached(url) {
189
+ const key = String(url || "");
190
+ if (!verified.has(key)) verified.set(key, verifyEndpoint(url));
191
+ return verified.get(key);
192
+ }
193
+
194
+ module.exports = { verifyEndpoint, verifyEndpointCached, parsePort, linuxSystem, darwinSystem, ancestry };
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,