@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
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,
package/core/io.js CHANGED
@@ -2,7 +2,16 @@
2
2
  // here so that the right adapter is invoked for the in-flight message
3
3
  // without each handler having to know about transports.
4
4
 
5
- const { currentAdapter, currentChannelId, currentUserId } = require("./context");
5
+ const { currentAdapter, currentChannelId, currentUserId, currentThreadId } = require("./context");
6
+
7
+ // The chat thread a delivery belongs to. An explicit opts.threadId wins (null =
8
+ // the channel's main timeline); a send aimed at ANOTHER channel never inherits
9
+ // this turn's thread; otherwise it goes back into the thread the turn is in.
10
+ function threadFor(opts = {}) {
11
+ if (opts && Object.prototype.hasOwnProperty.call(opts, "threadId")) return opts.threadId || null;
12
+ if (opts && opts.channelId && String(opts.channelId) !== String(currentChannelId() || "")) return null;
13
+ return currentThreadId();
14
+ }
6
15
 
7
16
  // Adapters historically overloaded their send() return value: a message id
8
17
  // (Telegram numeric, Kazee ObjectId string) on success, a bare `true` when
@@ -32,7 +41,8 @@ async function send(text, opts = {}) {
32
41
  console.error("send() called outside chat context");
33
42
  return { ok: false, messageId: null, editable: false };
34
43
  }
35
- return normalizeSendResult(await adapter.send(channelId, text, opts));
44
+ const threadId = threadFor(opts);
45
+ return normalizeSendResult(await adapter.send(channelId, text, threadId ? { ...opts, threadId } : opts));
36
46
  }
37
47
 
38
48
  async function editMessage(messageId, text, opts = {}) {
@@ -49,11 +59,12 @@ async function deleteMessage(messageId) {
49
59
  return adapter.delete(channelId, messageId);
50
60
  }
51
61
 
52
- async function sendVoice(oggPath) {
62
+ async function sendVoice(oggPath, opts = {}) {
53
63
  const adapter = currentAdapter();
54
64
  const channelId = currentChannelId();
55
65
  if (!adapter || !channelId) return false;
56
- return adapter.sendVoice(channelId, oggPath);
66
+ const threadId = threadFor(opts);
67
+ return threadId ? adapter.sendVoice(channelId, oggPath, { threadId }) : adapter.sendVoice(channelId, oggPath);
57
68
  }
58
69
 
59
70
  // Signal the end of a streamed multi-clip spoken reply. Only the voice channel
@@ -66,7 +77,7 @@ async function sendVoiceEnd() {
66
77
  return adapter.sendVoiceEnd(channelId);
67
78
  }
68
79
 
69
- async function sendFile(filePath, caption) {
80
+ async function sendFile(filePath, caption, opts = {}) {
70
81
  const adapter = currentAdapter();
71
82
  const channelId = currentChannelId();
72
83
  if (!adapter || !channelId) return false;
@@ -79,14 +90,16 @@ async function sendFile(filePath, caption) {
79
90
  return false;
80
91
  }
81
92
  } catch (e) { /* fail open only on classification error; primary gate is loopback.handleSend */ }
82
- return adapter.sendFile(channelId, filePath, caption);
93
+ const threadId = threadFor(opts);
94
+ return threadId ? adapter.sendFile(channelId, filePath, caption, { threadId }) : adapter.sendFile(channelId, filePath, caption);
83
95
  }
84
96
 
85
97
  async function typing(state) {
86
98
  const adapter = currentAdapter();
87
99
  const channelId = currentChannelId();
88
100
  if (!adapter || !channelId) return;
89
- return adapter.typing(channelId, state);
101
+ const threadId = currentThreadId();
102
+ return threadId ? adapter.typing(channelId, state, { threadId }) : adapter.typing(channelId, state);
90
103
  }
91
104
 
92
105
  function splitMessage(text, maxLen = 4000) {
@@ -96,4 +109,4 @@ function splitMessage(text, maxLen = 4000) {
96
109
  return chunks;
97
110
  }
98
111
 
99
- module.exports = { send, editMessage, deleteMessage, sendVoice, sendVoiceEnd, sendFile, typing, splitMessage, normalizeSendResult };
112
+ module.exports = { threadFor, send, editMessage, deleteMessage, sendVoice, sendVoiceEnd, sendFile, typing, splitMessage, normalizeSendResult };
package/core/jobs.js CHANGED
@@ -145,6 +145,9 @@ function normalizeRunnableJob(record, options = {}) {
145
145
  adapter: context.adapter,
146
146
  adapterType: context.adapterType,
147
147
  channelId: context.channelId,
148
+ // The chat thread the job fires back into (null = the channel's main
149
+ // timeline; jobs from before threads have none).
150
+ threadId: nonEmpty(record.threadId),
148
151
  canonicalUserId: context.canonicalUserId,
149
152
  userId: nonEmpty(record.userId),
150
153
  provider,
package/core/loopback.js CHANGED
@@ -137,6 +137,26 @@ function jobCreatorRecord(creator) {
137
137
  };
138
138
  }
139
139
 
140
+ // The chat thread a delivery or a job belongs to. The runner hands a thread
141
+ // turn OC_THREAD_ID, which the CLI sends as `threadId`. For a live, attested
142
+ // run the run decides, never the payload: in its own channel the run's thread
143
+ // (null on the main timeline, so a main turn cannot post into some other
144
+ // thread), and in any other channel no thread (a thread id belongs to its
145
+ // channel). A mismatching claim is logged. A caller with no live run is
146
+ // trusted exactly as its channelId is.
147
+ function threadForCaller(caller, channelId, claimed) {
148
+ const claim = claimed ? String(claimed) : null;
149
+ if (caller && caller.attributed) {
150
+ const sameChannel = String(caller.channelId || "") === String(channelId || "");
151
+ const attested = sameChannel && caller.threadId ? String(caller.threadId) : null;
152
+ if (claim && claim !== attested) {
153
+ console.error(`loopback: payload thread ${claim} does not match the running turn's thread ${attested || "(none)"}; using the turn's`);
154
+ }
155
+ return attested;
156
+ }
157
+ return claim;
158
+ }
159
+
140
160
  // Owner gate for every owner-only kind: true only for a LIVE run the bot
141
161
  // registered as acting for the owner. The id attest (OC_SPEAKER_ATTEST) is no
142
162
  // longer enough: it is valid for the whole boot and can be lifted from another
@@ -182,18 +202,27 @@ async function handleSend(req, res, url, kind) {
182
202
  }
183
203
  } catch (e) { /* classification failure → fall through; still owner-scoped in practice */ }
184
204
 
205
+ // Raw-body kinds carry the run attest in headers (the body is the file).
206
+ const caller = callerOf({
207
+ originRunId: req.headers["x-oc-run-id"] || "",
208
+ runAttest: req.headers["x-oc-run-attest"] || "",
209
+ });
210
+ const threadId = threadForCaller(caller, channelId, url.searchParams.get("threadId"));
211
+
185
212
  const safeName = path.basename(fileName).replace(/[^A-Za-z0-9._-]/g, "_") || `file-${Date.now()}`;
186
- const tmp = path.join(os.tmpdir(), `oc-loopback-${process.pid}-${Date.now()}-${safeName}`);
213
+ // Unique per request: parallel conversations may send the same file name
214
+ // in the same millisecond.
215
+ const tmp = path.join(os.tmpdir(), `oc-loopback-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}-${safeName}`);
187
216
  await readBodyToFile(req, tmp);
188
217
 
189
218
  let ok = false;
190
219
  try {
191
220
  if (kind === "send-voice") {
192
- ok = await adapter.sendVoice(channelId, tmp);
221
+ ok = threadId ? await adapter.sendVoice(channelId, tmp, { threadId }) : await adapter.sendVoice(channelId, tmp);
193
222
  } else if (kind === "send-photo" && typeof adapter.sendPhoto === "function") {
194
- ok = await adapter.sendPhoto(channelId, tmp, caption);
223
+ ok = threadId ? await adapter.sendPhoto(channelId, tmp, caption, { threadId }) : await adapter.sendPhoto(channelId, tmp, caption);
195
224
  } else {
196
- ok = await adapter.sendFile(channelId, tmp, caption);
225
+ ok = threadId ? await adapter.sendFile(channelId, tmp, caption, { threadId }) : await adapter.sendFile(channelId, tmp, caption);
197
226
  }
198
227
  } finally {
199
228
  if (kind !== "send-voice") { try { fs.unlinkSync(tmp); } catch (e) {} }
@@ -228,6 +257,7 @@ async function handleJson(req, res, url, kind) {
228
257
  // room, and all of their guards apply when it runs. An unattributed caller
229
258
  // (a process detached from any live turn) cannot schedule anything.
230
259
  let jobCreator = null;
260
+ let jobThreadId = null;
231
261
  if (JOB_CREATE_KINDS.has(kind)) {
232
262
  const caller = callerOf(payload);
233
263
  if (!caller.attributed) {
@@ -243,6 +273,8 @@ async function handleJson(req, res, url, kind) {
243
273
  owner: caller.owner === true,
244
274
  name: caller.name || null,
245
275
  };
276
+ // A job created in a thread fires back into that thread.
277
+ jobThreadId = threadForCaller(caller, jobCreator.channelId, payload.threadId);
246
278
  }
247
279
 
248
280
  // Owner-only kinds: notes, tasks, and the people reads. Tasks and people
@@ -290,9 +322,10 @@ async function handleJson(req, res, url, kind) {
290
322
  }
291
323
  if (!voicePath) return reply(res, 503, { error: "no TTS route available" });
292
324
 
325
+ const threadId = threadForCaller(callerOf(payload), channelId, payload.threadId);
293
326
  let ok = false;
294
327
  try {
295
- ok = await adapter.sendVoice(channelId, voicePath);
328
+ ok = threadId ? await adapter.sendVoice(channelId, voicePath, { threadId }) : await adapter.sendVoice(channelId, voicePath);
296
329
  } catch (e) {
297
330
  try { fs.unlinkSync(voicePath); } catch (_) {}
298
331
  return reply(res, 500, { error: `voice send failed: ${e.message}` });
@@ -363,6 +396,7 @@ async function handleJson(req, res, url, kind) {
363
396
  adapter: jobCreator.adapter,
364
397
  adapterType: jobCreator.adapterType,
365
398
  channelId: jobCreator.channelId,
399
+ threadId: jobThreadId,
366
400
  canonicalUserId: jobCreator.canonicalUserId,
367
401
  userId: jobCreator.userId,
368
402
  createdBy: jobCreatorRecord(jobCreator),
@@ -394,6 +428,7 @@ async function handleJson(req, res, url, kind) {
394
428
  adapter: jobCreator.adapter,
395
429
  adapterType: jobCreator.adapterType,
396
430
  channelId: jobCreator.channelId,
431
+ threadId: jobThreadId,
397
432
  canonicalUserId: jobCreator.canonicalUserId,
398
433
  userId: jobCreator.userId,
399
434
  createdBy: jobCreatorRecord(jobCreator),
@@ -442,6 +477,7 @@ async function handleJson(req, res, url, kind) {
442
477
  adapter: jobCreator.adapter,
443
478
  adapterType: jobCreator.adapterType,
444
479
  channelId: jobCreator.channelId,
480
+ threadId: jobThreadId,
445
481
  canonicalUserId: jobCreator.canonicalUserId,
446
482
  userId: jobCreator.userId,
447
483
  createdBy: jobCreatorRecord(jobCreator),
@@ -777,7 +813,12 @@ async function handleJson(req, res, url, kind) {
777
813
  targetChannel = caller.channelId;
778
814
  }
779
815
  if (!targetAdapter || !targetChannel) return reply(res, 400, { error: "need adapter+channelId or person" });
780
- const entries = readRecentTranscript(targetAdapter, targetChannel, limit);
816
+ // Optional thread filter: "main" = the channel's main timeline only, a
817
+ // thread id = that thread only; absent = the whole channel.
818
+ const thread = payload.thread === undefined || payload.thread === null || payload.thread === ""
819
+ ? undefined
820
+ : (String(payload.thread) === "main" ? null : String(payload.thread));
821
+ const entries = readRecentTranscript(targetAdapter, targetChannel, limit, { thread });
781
822
  return reply(res, 200, { ok: true, adapter: targetAdapter, channelId: targetChannel, entries });
782
823
  } catch (e) { return reply(res, 400, { error: e.message }); }
783
824
  }
@@ -830,9 +871,11 @@ async function handleJson(req, res, url, kind) {
830
871
  approverAdapter = owner.adapter;
831
872
  approverChannel = owner.channelId;
832
873
  }
874
+ const originThread = threadForCaller(callerOf(payload), channelId, payload.threadId);
833
875
  const rec = approvals.create({
834
876
  tool: payload.tool, verb: payload.verb || "", tier: payload.tier || "destructive",
835
877
  command: payload.command, channelId, adapter: adapterId,
878
+ threadId: originThread,
836
879
  canonicalUserId: payload.canonicalUserId || null,
837
880
  userId: payload.userId || null,
838
881
  provider: payload.provider || null,
@@ -860,6 +903,8 @@ async function handleJson(req, res, url, kind) {
860
903
  // <pre> needs Telegram's HTML parse mode to render; other adapters ignore it.
861
904
  const sendOpts = { keyboard };
862
905
  if (buttonAdapter.type === "telegram") sendOpts.parseMode = "HTML";
906
+ // The card goes into the thread the run is in, when it goes to that room.
907
+ if (originThread && buttonAdapter === adapter && String(buttonChannel) === String(channelId)) sendOpts.threadId = originThread;
863
908
  const { normalizeSendResult } = require("./io");
864
909
  const sent = normalizeSendResult(await buttonAdapter.send(buttonChannel, text, sendOpts));
865
910
  if (!sent.ok) { approvals.decide(rec.id, "denied", "send-failed"); return reply(res, 500, { error: "could not deliver approval prompt" }); }
@@ -875,7 +920,7 @@ async function handleJson(req, res, url, kind) {
875
920
  return reply(res, 404, { error: "not found" });
876
921
  }
877
922
 
878
- function readRecentTranscript(adapter, channelId, limit) {
923
+ function readRecentTranscript(adapter, channelId, limit, { thread } = {}) {
879
924
  if (!TRANSCRIPTS_DIR) return [];
880
925
  try {
881
926
  if (!fs2.existsSync(TRANSCRIPTS_DIR)) return [];
@@ -904,11 +949,14 @@ function readRecentTranscript(adapter, channelId, limit) {
904
949
  const objChannel = String(obj.chat?.id ?? obj.channelId ?? "");
905
950
  if (objAdapter !== adapterPrefix) continue;
906
951
  if (objChannel !== channelStr) continue;
952
+ const objThread = obj.chat?.threadId ? String(obj.chat.threadId) : null;
953
+ if (thread !== undefined && objThread !== thread) continue;
907
954
  out.push({
908
955
  at: obj.at || obj.timestamp || null,
909
956
  role: obj.role || obj.author || null,
910
957
  text: typeof obj.text === "string" ? obj.text.slice(0, 600) : null,
911
958
  kind: obj.kind || null,
959
+ threadId: objThread,
912
960
  });
913
961
  if (out.length >= limit) break;
914
962
  } catch (e) {}
@@ -85,8 +85,12 @@ function appendSessionArgs(args, context) {
85
85
  throw providerError("INVALID_PROVIDER_CONTEXT", "resumeSessionId and continueSession are mutually exclusive");
86
86
  }
87
87
  if (context.fresh) return;
88
- if (context.resumeSessionId) args.push("--resume", String(context.resumeSessionId));
89
- else if (context.continueSession) args.push("--continue");
88
+ if (context.resumeSessionId) {
89
+ args.push("--resume", String(context.resumeSessionId));
90
+ // A thread seeded from the main conversation (OC_THREAD_SEED=fork) resumes
91
+ // it into a new session of its own, leaving the main session untouched.
92
+ if (context.forkSession === true) args.push("--fork-session");
93
+ } else if (context.continueSession) args.push("--continue");
90
94
  else if (context.sessionId) args.push("--resume", String(context.sessionId));
91
95
  }
92
96