@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.
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
 
package/core/router.js CHANGED
@@ -5,13 +5,13 @@
5
5
  const fs = require("fs");
6
6
  const path = require("path");
7
7
  const { runInChat } = require("./context");
8
- const { conversationKeyFor } = require("./identity");
8
+ const { conversationKeyFor, roomKeyFor } = require("./identity");
9
9
  const { dispatch } = require("./commands");
10
10
  const { handleAction } = require("./actions");
11
11
  const { isChatAuthorized } = require("./access");
12
12
  const introFlow = require("./intro-flow");
13
13
  const { send, deleteMessage } = require("./io");
14
- const { currentState, saveState, autoLinkOwnerChannel, getActiveProvider, getProjectKey } = require("./state");
14
+ const { currentState, saveState, autoLinkOwnerChannel, getActiveProvider, getProjectKey, laneFor, peekLane, laneBusy, slotBlocker } = require("./state");
15
15
  const { admitRunContext, runAgent, startTypingHeartbeat } = require("./runner");
16
16
  const { transcribeAudio, cloneVoiceFromClip } = require("./media");
17
17
  const { hydrateContextMedia } = require("./context-media");
@@ -89,11 +89,13 @@ function scope(envelope, fn) {
89
89
  return runInChat({
90
90
  adapter: envelope.adapter,
91
91
  channelId: envelope.channelId,
92
+ threadId: envelope.threadId || null,
92
93
  canonicalUserId: envelope.canonicalUserId,
93
94
  userId: envelope.userId,
94
95
  transport: envelope.adapter?.type,
95
96
  raw: envelope.raw,
96
- conversationKey: conversationKeyFor(envelope.adapter?.type, envelope.channelId),
97
+ conversationKey: conversationKeyFor(envelope.adapter?.type, envelope.channelId, envelope.threadId),
98
+ roomKey: roomKeyFor(envelope.adapter?.type, envelope.channelId),
97
99
  inboundText: envelope.text || "",
98
100
  speakerName: envelope.from?.name || envelope.from?.username || "",
99
101
  isGroup: envelope.isGroup === true || envelope.surface === "group",
@@ -219,9 +221,13 @@ async function admitGuardedTurn(askText, envelope, state = currentState()) {
219
221
  // runAgent — many seconds with no feedback. Start the "thinking" heartbeat as
220
222
  // soon as a turn-bound message arrives; the run's own heartbeat supersedes it
221
223
  // at executeAdmittedRun, after which this stopper is a no-op. Skipped while a
222
- // run is active — the chip is already owned by that turn.
224
+ // run is active in this conversation — the chip is already owned by that turn
225
+ // (another conversation's run has its own chip and does not matter here) —
226
+ // and when the message will WAIT (the conversation already has a queue, or no
227
+ // slot is free): nothing is thinking about it yet.
223
228
  function startPreTurnTyping(state) {
224
- if (state.runningProcess || state.preparingRun) return () => {};
229
+ const lane = peekLane(state);
230
+ if (laneBusy(lane) || lane?.messageQueue?.length || slotBlocker(state)) return () => {};
225
231
  try { return startTypingHeartbeat(state); } catch (_) { return () => {}; }
226
232
  }
227
233
 
@@ -237,9 +243,10 @@ async function handleVoice(envelope) {
237
243
  }
238
244
  // /voice clone armed this channel: the next clip is a cloning sample, not a
239
245
  // prompt. Consume the flag (fresh only) before the normal transcribe path.
240
- if (state.pendingVoiceClone) {
241
- const fresh = Date.now() - state.pendingVoiceClone.at < 5 * 60 * 1000;
242
- state.pendingVoiceClone = null;
246
+ const lane = laneFor(state);
247
+ if (lane.pendingVoiceClone) {
248
+ const fresh = Date.now() - lane.pendingVoiceClone.at < 5 * 60 * 1000;
249
+ lane.pendingVoiceClone = null;
243
250
  if (fresh) return captureVoiceClone(envelope, media);
244
251
  }
245
252
  const runContext = admitRunContext("", state.currentSession.dir, envelope.messageId, {
@@ -256,9 +263,13 @@ async function handleVoice(envelope) {
256
263
  if (!transcript) return send("Couldn't transcribe. Try typing it.");
257
264
  await send(`Heard: "${transcript}"`, { replyTo: envelope.messageId });
258
265
  if (!(await admitGuardedTurn(transcript, envelope, state))) return;
259
- state.lastInputWasVoice = true;
266
+ // Voice flags live on the conversation's lane: a voice note in one thread
267
+ // never makes another conversation's reply speak. Resolved again after the
268
+ // awaits: an idle lane may have been pruned and recreated meanwhile.
269
+ const voiceLane = laneFor(state);
270
+ voiceLane.lastInputWasVoice = true;
260
271
  // Per-stage latency for the voice pipeline; the runner fills in the rest.
261
- state.voiceLat = { startAt: tStart, downloadMs: tAfterDownload - tStart, sttMs: tAfterStt - tAfterDownload };
272
+ voiceLane.voiceLat = { startAt: tStart, downloadMs: tAfterDownload - tStart, sttMs: tAfterStt - tAfterDownload };
262
273
  return runWithContext(transcript, runContext, envelope, {
263
274
  runContext,
264
275
  attachments: [{ type: "voice", path: oggPath, mimeType: media.mimeType || "audio/ogg", fileName: media.fileName || null }],
@@ -276,9 +287,10 @@ async function handleAudio(envelope) {
276
287
  try {
277
288
  const media = envelope.media?.[0];
278
289
  if (!media) return;
279
- if (state.pendingVoiceClone) {
280
- const fresh = Date.now() - state.pendingVoiceClone.at < 5 * 60 * 1000;
281
- state.pendingVoiceClone = null;
290
+ const lane = laneFor(state);
291
+ if (lane.pendingVoiceClone) {
292
+ const fresh = Date.now() - lane.pendingVoiceClone.at < 5 * 60 * 1000;
293
+ lane.pendingVoiceClone = null;
282
294
  if (fresh) return captureVoiceClone(envelope, media);
283
295
  }
284
296
  const runContext = admitRunContext("", state.currentSession.dir, envelope.messageId, {
@@ -2,8 +2,9 @@
2
2
 
3
3
  const crypto = require("crypto");
4
4
  const path = require("path");
5
- const { currentConversationKey } = require("./context");
6
- const { resolveProviderSettings } = require("./state");
5
+ const { currentConversationKey, currentThreadId } = require("./context");
6
+ const { resolveProviderSettings, peekLane } = require("./state");
7
+ const { parseConversationKey } = require("./identity");
7
8
 
8
9
  function cloneSerializable(value, label = "run context value") {
9
10
  if (value === undefined) return undefined;
@@ -51,6 +52,28 @@ function projectSnapshot(state, cwd, requestedProject) {
51
52
  return { name: dir ? path.basename(dir) : null, dir };
52
53
  }
53
54
 
55
+ // OC_THREAD_SEED=fork (Claude only): a thread's FIRST turn resumes the
56
+ // channel's main conversation with --fork-session, so the thread inherits the
57
+ // whole main context in a session of its own. Default "fresh": a new thread
58
+ // starts a new session, seeded only with the thread's own messages. Codex has
59
+ // no fork, so it always starts fresh.
60
+ function threadSeedMode() {
61
+ let value = process.env.OC_THREAD_SEED;
62
+ try { value = require("./config").config?.OC_THREAD_SEED ?? value; } catch (_) {}
63
+ return String(value || "fresh").trim().toLowerCase() === "fork" ? "fork" : "fresh";
64
+ }
65
+
66
+ function threadForkSession(state, { provider, laneKey, threadId, activeSessionId, opts, liveCall }) {
67
+ if (provider !== "claude" || !threadId || activeSessionId || liveCall) return null;
68
+ if (opts.fresh || opts.resumeSessionId || opts.continueSession || opts.project !== undefined) return null;
69
+ if ((opts.purpose || "foreground") !== "foreground") return null;
70
+ if (threadSeedMode() !== "fork") return null;
71
+ const mainKey = parseConversationKey(laneKey).roomKey;
72
+ const mainSessionId = mainKey && mainKey !== laneKey ? state.activeSessions?.[mainKey]?.claude || null : null;
73
+ if (!mainSessionId || state.quarantinedSessions?.[mainSessionId]) return null;
74
+ return mainSessionId;
75
+ }
76
+
54
77
  function serializableOrigin(origin, fallback = {}) {
55
78
  const source = origin || fallback || {};
56
79
  return {
@@ -102,14 +125,22 @@ function captureRunContext({
102
125
  // session associated with the same chat room.
103
126
  const liveCall = store?.adapter?.liveCall === true;
104
127
  const activeSessionId = state.activeSessions?.[project.name]?.[provider] || null;
128
+ // The run's lane (its conversation: a thread is its own) and thread are
129
+ // fixed here, at admission: queueing, /stop, preview, typing and delivery all
130
+ // follow the lane the message arrived in, never a later one.
131
+ const laneKey = String(opts.laneKey || currentConversationKey() || project.name || "Workspace");
132
+ const threadId = (store && store.threadId) || currentThreadId() || parseConversationKey(laneKey).threadId || null;
133
+ const lane = peekLane(state, laneKey);
134
+ const forkFromSessionId = threadForkSession(state, { provider, laneKey, threadId, activeSessionId, opts, liveCall });
105
135
  const requestedSessionId = opts.fresh || liveCall
106
136
  ? null
107
- : (opts.resumeSessionId || activeSessionId || null);
137
+ : (opts.resumeSessionId || activeSessionId || forkFromSessionId || null);
108
138
  // A quarantined session's provider-side context tripped a safety refusal;
109
139
  // resuming would re-send the flagged context and refuse again. Force the
110
140
  // turn fresh instead (explicit resume requests included).
111
141
  const quarantinedResume = !!(requestedSessionId && state.quarantinedSessions?.[requestedSessionId]);
112
142
  const admittedSessionId = quarantinedResume ? null : requestedSessionId;
143
+ const forkedFromMain = !!forkFromSessionId && admittedSessionId === forkFromSessionId;
113
144
  const origin = serializableOrigin(opts.origin, store || {});
114
145
  const resolvedChannelId = channelId != null
115
146
  ? String(channelId)
@@ -132,12 +163,18 @@ function captureRunContext({
132
163
  ? crypto.randomUUID()
133
164
  : `run-${now}-${Math.random().toString(36).slice(2, 10)}`),
134
165
  admittedAt: now,
166
+ laneKey,
167
+ threadId: threadId ? String(threadId) : null,
135
168
  canonicalUserId: canonicalUserId || state.userId || origin.canonicalUserId || null,
136
169
  project,
137
170
  provider,
138
171
  providerSettings,
139
- sessionId: admittedSessionId,
140
- admittedSessionId,
172
+ // A fork turn resumes the MAIN session only to copy it: the turn's own
173
+ // session does not exist until the provider reports it. Never expose the
174
+ // main id as this run's session (OC_SESSION_ID, jobs and approvals made in
175
+ // the turn), or a later wakeup would --resume the main conversation.
176
+ sessionId: forkedFromMain ? null : admittedSessionId,
177
+ admittedSessionId: forkedFromMain ? null : admittedSessionId,
141
178
  previousSessionId: activeSessionId,
142
179
  purpose: opts.purpose || (opts.label ? String(opts.label) : "foreground"),
143
180
  channelId: resolvedChannelId,
@@ -147,15 +184,18 @@ function captureRunContext({
147
184
  userPrompt: prompt,
148
185
  fresh: !!opts.fresh || liveCall || quarantinedResume,
149
186
  continueSession: quarantinedResume || liveCall ? false : !!opts.continueSession,
150
- resumeSessionId: quarantinedResume || liveCall ? null : (opts.resumeSessionId || null),
187
+ resumeSessionId: quarantinedResume || liveCall ? null : (opts.resumeSessionId || forkFromSessionId || null),
188
+ // Claude --fork-session: resume the main conversation into a NEW session
189
+ // (the thread's own), leaving the main session untouched.
190
+ forkSession: !!forkFromSessionId && !quarantinedResume,
151
191
  quarantinedResume,
152
192
  skipAutoCompact: liveCall || !!opts.skipAutoCompact,
153
193
  timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : null,
154
194
  maxTurns: Number.isFinite(opts.maxTurns) ? opts.maxTurns : null,
155
195
  imagePaths: cloneSerializable(opts.imagePaths || opts.images || [], "image paths"),
156
196
  attachments: cloneSerializable(opts.attachments || [], "attachments"),
157
- lastInputWasVoice: !!state.lastInputWasVoice,
158
- voiceStream: !!opts.voiceStream || (!!state.lastInputWasVoice && origin.transport === "voice"),
197
+ lastInputWasVoice: !!lane?.lastInputWasVoice,
198
+ voiceStream: !!opts.voiceStream || (!!lane?.lastInputWasVoice && origin.transport === "voice"),
159
199
  liveCall,
160
200
  callHistory: liveCall
161
201
  ? cloneSerializable(store.adapter.callHistory || [], "call history") : [],
@@ -221,4 +261,5 @@ module.exports = {
221
261
  deepFreeze,
222
262
  sameCapturedProject,
223
263
  serializableOrigin,
264
+ threadSeedMode,
224
265
  };