@luckydraw/cumulus 1.0.7 → 1.0.9

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 (48) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/gateway/adapters/webchat.d.ts.map +1 -1
  3. package/dist/gateway/adapters/webchat.js +173 -2
  4. package/dist/gateway/adapters/webchat.js.map +1 -1
  5. package/dist/gateway/gateway-agents-mcp.js +81 -0
  6. package/dist/gateway/gateway-agents-mcp.js.map +1 -1
  7. package/dist/gateway/server.d.ts +65 -0
  8. package/dist/gateway/server.d.ts.map +1 -1
  9. package/dist/gateway/server.js +335 -15
  10. package/dist/gateway/server.js.map +1 -1
  11. package/dist/gateway/static/widget.js +581 -66
  12. package/dist/lib/config.d.ts +48 -0
  13. package/dist/lib/config.d.ts.map +1 -1
  14. package/dist/lib/config.js +43 -0
  15. package/dist/lib/config.js.map +1 -1
  16. package/dist/lib/content-store.d.ts +64 -1
  17. package/dist/lib/content-store.d.ts.map +1 -1
  18. package/dist/lib/content-store.js +161 -48
  19. package/dist/lib/content-store.js.map +1 -1
  20. package/dist/lib/embeddings.d.ts +11 -1
  21. package/dist/lib/embeddings.d.ts.map +1 -1
  22. package/dist/lib/embeddings.js +301 -40
  23. package/dist/lib/embeddings.js.map +1 -1
  24. package/dist/lib/gateway.d.ts +67 -1
  25. package/dist/lib/gateway.d.ts.map +1 -1
  26. package/dist/lib/gateway.js +133 -12
  27. package/dist/lib/gateway.js.map +1 -1
  28. package/dist/lib/retriever.d.ts +34 -2
  29. package/dist/lib/retriever.d.ts.map +1 -1
  30. package/dist/lib/retriever.js +9 -2
  31. package/dist/lib/retriever.js.map +1 -1
  32. package/dist/lib/tool-inventory.d.ts.map +1 -1
  33. package/dist/lib/tool-inventory.js +3 -0
  34. package/dist/lib/tool-inventory.js.map +1 -1
  35. package/dist/lib/worktree.d.ts +164 -0
  36. package/dist/lib/worktree.d.ts.map +1 -0
  37. package/dist/lib/worktree.js +348 -0
  38. package/dist/lib/worktree.js.map +1 -0
  39. package/dist/mcp/index.js +7 -1
  40. package/dist/mcp/index.js.map +1 -1
  41. package/dist/mcp/server.d.ts +4 -1
  42. package/dist/mcp/server.d.ts.map +1 -1
  43. package/dist/mcp/server.js +4 -2
  44. package/dist/mcp/server.js.map +1 -1
  45. package/dist/mcp/tool-handler.d.ts.map +1 -1
  46. package/dist/mcp/tool-handler.js +11 -0
  47. package/dist/mcp/tool-handler.js.map +1 -1
  48. package/package.json +1 -1
@@ -20,7 +20,7 @@ import * as fs from 'fs';
20
20
  import * as http from 'http';
21
21
  import * as os from 'os';
22
22
  import * as path from 'path';
23
- import { loadGlobalConfig, loadThreadConfig, mergeConfigs, saveThreadConfig, setConfigThreadsDir, } from '../lib/config.js';
23
+ import { loadGlobalConfig, loadThreadConfig, loadThreadConfigExact, mergeConfigs, resolveCoThreadMaster, saveThreadConfig, setConfigThreadsDir, } from '../lib/config.js';
24
24
  import { loadEmbeddings, generateMissingEmbeddings } from '../lib/embeddings.js';
25
25
  import { sendMessage, getOrCreateThread, clearThreadCache, canRunBackgroundWork, getActiveSubprocessCount, setGatewayThreadsDir, } from '../lib/gateway.js';
26
26
  import { verifyLicense, majorOf, LICENSE_CONTACT } from '../lib/license.js';
@@ -28,6 +28,7 @@ import { getPromptsDirForThreadPath, isValidCaptureId, readPromptCapture, } from
28
28
  import { catchUpSegmentation, loadSegmentBoundaries, saveSegmentBoundaries, } from '../lib/segments.js';
29
29
  import { listTemplates, scaffoldFromTemplate } from '../lib/templates.js';
30
30
  import { checkForUpdate, getCurrentVersion, performUpdate, readChangelog, } from '../lib/version-check.js';
31
+ import { coThreadOverlaps, coThreadStatus, ensureCoThreadWorktree, mergeCoThread, removeCoThreadWorktree, resolveThreadCwd, } from '../lib/worktree.js';
31
32
  import { serveStaticFile, getMediaDir } from './adapters/webchat.js';
32
33
  import { authenticate, getPresentedKey } from './auth.js';
33
34
  import { BridgeGateway } from './bridge/gateway.js';
@@ -40,6 +41,98 @@ import { isAutomatedSender } from './senders.js';
40
41
  import { validateTranscriptFlush, ingestTranscriptFlush } from './transcript-ingest.js';
41
42
  const threadQueues = new Map();
42
43
  const threadBusy = new Map();
44
+ const userQueues = new Map();
45
+ let userQueueDelivery;
46
+ export function setUserQueueDelivery(fn) {
47
+ userQueueDelivery = fn;
48
+ }
49
+ /** Queue a deferred user message. Returns the full pending list for the thread. */
50
+ export function enqueueUserMessage(threadName, msg) {
51
+ const queue = userQueues.get(threadName) ?? [];
52
+ queue.push({ ...msg, id: msg.id ?? `q_${Math.random().toString(36).slice(2, 10)}` });
53
+ userQueues.set(threadName, queue);
54
+ console.log(`[Gateway] User message queued for busy thread "${threadName}" (position ${queue.length})`);
55
+ return queue;
56
+ }
57
+ /** Pending deferred user messages for a thread (empty array when none). */
58
+ export function getUserQueue(threadName) {
59
+ return userQueues.get(threadName) ?? [];
60
+ }
61
+ /** Edit a queued message's text in place. Returns the updated pending list. */
62
+ export function editUserMessage(threadName, id, text) {
63
+ const queue = userQueues.get(threadName) ?? [];
64
+ const item = queue.find(m => m.id === id);
65
+ if (item)
66
+ item.text = text;
67
+ return queue;
68
+ }
69
+ /**
70
+ * Remove one queued message and return it — the shared half of "cancel" and
71
+ * "send now". Send-now is cancel plus an ordinary send, so it cannot leave a
72
+ * copy behind in the queue.
73
+ */
74
+ export function takeUserMessage(threadName, id) {
75
+ const queue = userQueues.get(threadName) ?? [];
76
+ const idx = queue.findIndex(m => m.id === id);
77
+ const taken = idx >= 0 ? queue.splice(idx, 1)[0] : undefined;
78
+ if (queue.length === 0)
79
+ userQueues.delete(threadName);
80
+ return { taken, remaining: queue };
81
+ }
82
+ /**
83
+ * Render a drained batch as the text of one turn.
84
+ *
85
+ * A single message delivers as its bare text — indistinguishable from one sent
86
+ * normally, because on the common case a wrapper is pure noise. Several deliver
87
+ * as one turn (Karl's choice over one-at-a-time: the model sees the whole
88
+ * picture before it starts answering).
89
+ *
90
+ * Deliberately carries no `[sender → recipient]` framing and no reply hint:
91
+ * this is the user talking, not an agent.
92
+ */
93
+ export function formatQueuedUserBatch(batch) {
94
+ if (batch.length === 1)
95
+ return batch[0].text;
96
+ const lines = [
97
+ `[${batch.length} messages queued while you were responding — all from the user, in order]`,
98
+ '',
99
+ ];
100
+ batch.forEach((m, i) => {
101
+ lines.push(`(${i + 1}) ${m.text}`);
102
+ lines.push('');
103
+ });
104
+ return lines.join('\n').trimEnd();
105
+ }
106
+ /**
107
+ * Attempt a drain outside the busy→idle transition.
108
+ *
109
+ * Needed because delivery can decline: with no client watching the thread there
110
+ * is nobody to stream the turn to, so the batch is put back. Without this, those
111
+ * messages would wait for the *next* busy→idle — which may never come on an
112
+ * idle thread. A client re-opening the thread calls this.
113
+ */
114
+ export function flushUserQueue(threadName) {
115
+ return drainUserQueue(threadName);
116
+ }
117
+ /**
118
+ * Deliver every pending user message for a thread as one turn.
119
+ *
120
+ * Returns true when it took the thread — the caller must NOT also drain the
121
+ * agent queue, or the two turns run concurrently (the task 120 class).
122
+ */
123
+ function drainUserQueue(threadName) {
124
+ const queue = userQueues.get(threadName);
125
+ if (!queue || queue.length === 0)
126
+ return false;
127
+ if (threadBusy.get(threadName))
128
+ return false;
129
+ if (!userQueueDelivery)
130
+ return false;
131
+ userQueues.delete(threadName);
132
+ console.log(`[Gateway] Draining ${queue.length} queued user message(s) for "${threadName}"`);
133
+ userQueueDelivery(threadName, queue);
134
+ return true;
135
+ }
43
136
  const bridgeContexts = new Map();
44
137
  async function buildBridgeContextBlock(threadName, executorUrl) {
45
138
  const ctx = bridgeContexts.get(threadName);
@@ -108,7 +201,15 @@ export function markThreadBusy(threadName, busy) {
108
201
  // User-queued messages take priority: their drain pass toggles busy and
109
202
  // lands back here when the user queue is empty.
110
203
  const pendingUser = threadQueues.get(threadName);
111
- if (!pendingUser?.length && agentPipelineOpts) {
204
+ if (pendingUser?.length)
205
+ return;
206
+ // Deferred user messages (task 144) outrank agent messages for the same
207
+ // reason the SSE queue does. If this takes the thread it returns true, and we
208
+ // must NOT also start an agent turn — that is two concurrent turns on one
209
+ // thread (task 120).
210
+ if (drainUserQueue(threadName))
211
+ return;
212
+ if (agentPipelineOpts) {
112
213
  drainAgentQueue(threadName, agentPipelineOpts);
113
214
  }
114
215
  }
@@ -718,6 +819,28 @@ export function formatAgentMessageForRecipient(messageText, senderName, recipien
718
819
  : automatedNote;
719
820
  return `${prefix}\n${messageText}\n\n${replyHint}`;
720
821
  }
822
+ /**
823
+ * Render several drained agent messages as one turn.
824
+ *
825
+ * Exported because the widget has to RECOGNISE this shape to render it as an
826
+ * agent message rather than a user bubble (task 144) — and a hand-copied
827
+ * fixture of it is exactly what task 103 got wrong: the tests passed against a
828
+ * shape that never occurs in production. The widget test drives this function.
829
+ */
830
+ export function formatAgentBatch(queue) {
831
+ const lines = [`[While you were busy, ${queue.length} messages arrived]\n`];
832
+ for (const msg of queue) {
833
+ const time = new Date(msg.timestamp).toLocaleTimeString();
834
+ // Task 110: don't call a script an agent
835
+ const kind = isAgentSender(msg.sender) ? 'agent' : 'automated sender';
836
+ lines.push(`[From ${kind} "${msg.sender}"] (${time}):`);
837
+ lines.push(msg.text);
838
+ lines.push('');
839
+ }
840
+ lines.push('(Review the above messages. Reply only if actionable work is needed.');
841
+ lines.push('Use send_to_agent("name", response) to respond to a specific agent — automated senders are scripts and cannot be replied to.)');
842
+ return lines.join('\n');
843
+ }
721
844
  function drainAgentQueue(threadName, pipelineOpts) {
722
845
  // Another turn may have grabbed the thread between busy→false and this call
723
846
  if (threadBusy.get(threadName))
@@ -739,18 +862,7 @@ function drainAgentQueue(threadName, pipelineOpts) {
739
862
  });
740
863
  }
741
864
  else {
742
- const lines = [`[While you were busy, ${queue.length} messages arrived]\n`];
743
- for (const msg of queue) {
744
- const time = new Date(msg.timestamp).toLocaleTimeString();
745
- // Task 110: don't call a script an agent
746
- const kind = isAgentSender(msg.sender) ? 'agent' : 'automated sender';
747
- lines.push(`[From ${kind} "${msg.sender}"] (${time}):`);
748
- lines.push(msg.text);
749
- lines.push('');
750
- }
751
- lines.push('(Review the above messages. Reply only if actionable work is needed.');
752
- lines.push('Use send_to_agent("name", response) to respond to a specific agent — automated senders are scripts and cannot be replied to.)');
753
- formatted = lines.join('\n');
865
+ formatted = formatAgentBatch(queue);
754
866
  }
755
867
  // Fire-and-forget: send the batched message as a new turn
756
868
  threadBusy.set(threadName, true);
@@ -973,6 +1085,15 @@ async function handleListThreads(res, scopeNs, namespaces) {
973
1085
  if (tc.projectDir) {
974
1086
  entry.projectDir = tc.projectDir;
975
1087
  }
1088
+ // Co-thread membership, so the sidebar can nest (task 143). Deliberately
1089
+ // the authoritative predicate and NOT a name-prefix test: every visitor
1090
+ // thread (`pursuit-<deviceId>`) looks like a co-thread by name, and
1091
+ // grouping those under their app would claim a relationship that does
1092
+ // not exist. `resolveCoThreadMaster` requires actual membership.
1093
+ const master = resolveCoThreadMaster(name);
1094
+ if (master) {
1095
+ entry.master = master;
1096
+ }
976
1097
  threads.push(entry);
977
1098
  }
978
1099
  // Sort by last activity descending
@@ -1001,6 +1122,24 @@ async function handleDeleteThread(threadName, req, res) {
1001
1122
  return;
1002
1123
  }
1003
1124
  try {
1125
+ // A co-thread's private worktree is a sidecar like any other: remove it with
1126
+ // the thread, or it lingers on disk registered to a repo forever (task 143 p4).
1127
+ const deletedThreadMaster = resolveCoThreadMaster(threadName);
1128
+ if (deletedThreadMaster) {
1129
+ try {
1130
+ await removeCoThreadWorktree(threadName, await projectDirFor(deletedThreadMaster));
1131
+ // Deregister, or the name stays listed as a co-thread forever: creation
1132
+ // short-circuits on an already-registered name, so recreating it would
1133
+ // report success and never rebuild the history file.
1134
+ const masterConfig = await loadThreadConfigExact(deletedThreadMaster);
1135
+ await saveThreadConfig(deletedThreadMaster, {
1136
+ coThreads: (masterConfig.coThreads ?? []).filter(n => n !== threadName),
1137
+ });
1138
+ }
1139
+ catch (err) {
1140
+ console.warn(`[Gateway] worktree cleanup failed for ${threadName}:`, err);
1141
+ }
1142
+ }
1004
1143
  // Clear from cache
1005
1144
  clearThreadCache(threadName);
1006
1145
  threadQueues.delete(threadName);
@@ -1355,6 +1494,160 @@ async function handleUpdatePipeline(threadName, req, res) {
1355
1494
  jsonResponse(res, 500, { error: String(err) });
1356
1495
  }
1357
1496
  }
1497
+ // ─── Co-thread handlers (task 143) ─────────────────────────
1498
+ /** A co-thread label: one path-safe segment, no separators of its own. */
1499
+ const CO_THREAD_LABEL = /^[a-zA-Z0-9_]+$/;
1500
+ /** The project directory a thread's work belongs to (the master's, for a co-thread). */
1501
+ async function projectDirFor(threadName) {
1502
+ const merged = mergeConfigs(await loadGlobalConfig(), await loadThreadConfig(threadName));
1503
+ return merged.projectDir;
1504
+ }
1505
+ async function handleListCoThreads(threadName, res) {
1506
+ try {
1507
+ // The set belongs to the MASTER, so a co-thread asking has to read it from
1508
+ // there — its own exact config is deliberately absent (that absence is what
1509
+ // preserves task 098 inheritance), which is why this used to answer a
1510
+ // co-thread with an empty list. Task 143 phase 5: visibility is not
1511
+ // authority — create and merge stay master-only and still refuse with 409.
1512
+ const master = resolveCoThreadMaster(threadName);
1513
+ const owner = master ?? threadName;
1514
+ const config = await loadThreadConfigExact(owner);
1515
+ const names = config.coThreads ?? [];
1516
+ const masterDir = await projectDirFor(owner);
1517
+ // Report each co-thread's private tree so the set can see who has
1518
+ // uncommitted work and who has commits waiting to be merged.
1519
+ const status = await Promise.all(names.map(n => coThreadStatus(n, masterDir)));
1520
+ jsonResponse(res, 200, {
1521
+ thread: threadName,
1522
+ master: master ?? null,
1523
+ coThreads: names,
1524
+ worktrees: status,
1525
+ overlaps: coThreadOverlaps(status),
1526
+ });
1527
+ }
1528
+ catch (err) {
1529
+ jsonResponse(res, 500, { error: String(err) });
1530
+ }
1531
+ }
1532
+ /**
1533
+ * Merge a co-thread's branch into the master's working tree.
1534
+ *
1535
+ * Master-only, mirroring creation: the co-thread does not decide when its work
1536
+ * lands, and the master's checkout has exactly one thread able to move it.
1537
+ */
1538
+ async function handleMergeCoThread(threadName, label, res) {
1539
+ try {
1540
+ if (resolveCoThreadMaster(threadName)) {
1541
+ jsonResponse(res, 409, {
1542
+ error: `"${threadName}" is itself a co-thread. Merging is done from the master.`,
1543
+ });
1544
+ return;
1545
+ }
1546
+ const coThreadName = label.includes('-') ? label : `${threadName}-${label}`;
1547
+ const config = await loadThreadConfigExact(threadName);
1548
+ if (!(config.coThreads ?? []).includes(coThreadName)) {
1549
+ jsonResponse(res, 404, { error: `"${coThreadName}" is not a co-thread of "${threadName}".` });
1550
+ return;
1551
+ }
1552
+ const result = await mergeCoThread(coThreadName, await projectDirFor(threadName));
1553
+ jsonResponse(res, result.error ? 409 : 200, { thread: coThreadName, ...result });
1554
+ }
1555
+ catch (err) {
1556
+ jsonResponse(res, 500, { error: String(err) });
1557
+ }
1558
+ }
1559
+ /**
1560
+ * Create a co-thread of `threadName`: a sibling that shares this thread's
1561
+ * content store but keeps its own conversation history.
1562
+ *
1563
+ * Creation is MASTER-ONLY (Karl, 2026-08-16). A co-thread cannot mint siblings,
1564
+ * so the tree stays exactly one level deep and ownership of the project
1565
+ * checkout is never ambiguous.
1566
+ */
1567
+ async function handleCreateCoThread(threadName, req, res, jobs) {
1568
+ let body;
1569
+ try {
1570
+ body = JSON.parse(await readBody(req));
1571
+ }
1572
+ catch {
1573
+ jsonResponse(res, 400, { error: 'Invalid JSON body' });
1574
+ return;
1575
+ }
1576
+ const label = (body.label ?? '').trim();
1577
+ if (!label || !CO_THREAD_LABEL.test(label)) {
1578
+ jsonResponse(res, 400, {
1579
+ error: 'label must be letters, numbers or underscores (no hyphens, spaces or slashes)',
1580
+ });
1581
+ return;
1582
+ }
1583
+ const master = resolveCoThreadMaster(threadName);
1584
+ if (master) {
1585
+ jsonResponse(res, 409, {
1586
+ error: `"${threadName}" is itself a co-thread of "${master}". Co-threads are created from the master only.`,
1587
+ });
1588
+ return;
1589
+ }
1590
+ const coThreadName = `${threadName}-${label}`;
1591
+ const coThreadPath = path.join(THREADS_DIR, `${coThreadName}.jsonl`);
1592
+ try {
1593
+ const config = await loadThreadConfigExact(threadName);
1594
+ const existing = config.coThreads ?? [];
1595
+ if (existing.includes(coThreadName)) {
1596
+ jsonResponse(res, 200, { created: false, thread: coThreadName, master: threadName });
1597
+ return;
1598
+ }
1599
+ if (fs.existsSync(coThreadPath)) {
1600
+ jsonResponse(res, 409, {
1601
+ error: `Thread "${coThreadName}" already exists and is not a co-thread. Pick another label.`,
1602
+ });
1603
+ return;
1604
+ }
1605
+ // Register FIRST: an unregistered thread that already has a history file
1606
+ // would come up with a private store, and its first turn's memory would
1607
+ // land somewhere the master can never see.
1608
+ await saveThreadConfig(threadName, { coThreads: [...existing, coThreadName] });
1609
+ // The thread exists once its history file does — that is what every
1610
+ // listing (`/api/threads`, `/api/agents`, the web UI) enumerates.
1611
+ fs.mkdirSync(THREADS_DIR, { recursive: true });
1612
+ fs.writeFileSync(coThreadPath, '', { flag: 'a' });
1613
+ // Drop any cached state so the next turn resolves the shared store.
1614
+ clearThreadCache(coThreadName);
1615
+ // Give it a private checkout so it cannot overwrite its siblings (phase 4).
1616
+ // Best-effort: a non-git project still gets a working co-thread, sharing the
1617
+ // checkout exactly as it did before this shipped.
1618
+ const masterDir = await projectDirFor(threadName);
1619
+ const wt = await ensureCoThreadWorktree(coThreadName, masterDir);
1620
+ // A fresh worktree has the tracked files and nothing else. Run the project's
1621
+ // setup command through the job system (task 139) so creation does not block
1622
+ // on it and the co-thread is woken when its tree is actually usable.
1623
+ let setupJob;
1624
+ if (jobs && wt.worktree && config.worktreeSetup && (await threadCanRunJobs(coThreadName))) {
1625
+ try {
1626
+ setupJob = jobs.create({
1627
+ thread: coThreadName,
1628
+ command: config.worktreeSetup,
1629
+ label: 'worktree setup',
1630
+ cwd: wt.cwd,
1631
+ }).id;
1632
+ }
1633
+ catch (err) {
1634
+ console.warn(`[Gateway] worktree setup failed to start for ${coThreadName}:`, err);
1635
+ }
1636
+ }
1637
+ jsonResponse(res, 201, {
1638
+ created: true,
1639
+ thread: coThreadName,
1640
+ master: threadName,
1641
+ cwd: wt.cwd,
1642
+ worktree: wt.worktree ?? null,
1643
+ sharedCheckout: wt.fellBackTo?.reason ?? null,
1644
+ setupJob: setupJob ?? null,
1645
+ });
1646
+ }
1647
+ catch (err) {
1648
+ jsonResponse(res, 500, { error: String(err) });
1649
+ }
1650
+ }
1358
1651
  // ─── Schedule handlers ─────────────────────────────────────
1359
1652
  async function handleListSchedules(threadName, res) {
1360
1653
  try {
@@ -1487,7 +1780,12 @@ async function handleCreateJob(threadName, req, res, jobs, projectRoot) {
1487
1780
  if (!cwd) {
1488
1781
  try {
1489
1782
  const merged = mergeConfigs(await loadGlobalConfig(), await loadThreadConfig(threadName));
1490
- cwd = merged.projectDir;
1783
+ // Task 143 phase 4: for a co-thread that is the PRIVATE WORKTREE, not the
1784
+ // master's checkout. Resolving only the config here would build and test a
1785
+ // tree the thread never edits — the same-tree invariant above, broken
1786
+ // silently. One resolver, both cwd sites (task 113).
1787
+ const resolved = await resolveThreadCwd(threadName, merged.projectDir);
1788
+ cwd = resolved.cwd || merged.projectDir;
1491
1789
  }
1492
1790
  catch {
1493
1791
  /* fall through to the defaults below */
@@ -2227,6 +2525,28 @@ export async function startGatewayServer(options) {
2227
2525
  await handleUpdatePipeline(params.name, req, res);
2228
2526
  return;
2229
2527
  }
2528
+ // GET /api/thread/:name/co-threads — list co-threads (task 143)
2529
+ if (routePath === '/api/thread/:name/co-threads' && req.method === 'GET') {
2530
+ await handleListCoThreads(params.name, res);
2531
+ return;
2532
+ }
2533
+ // POST /api/thread/:name/co-threads — create a co-thread (master only)
2534
+ if (routePath === '/api/thread/:name/co-threads' && req.method === 'POST') {
2535
+ await handleCreateCoThread(params.name, req, res, jobRegistry);
2536
+ return;
2537
+ }
2538
+ // POST /api/thread/:name/co-threads/:label/merge — fold a co-thread's
2539
+ // branch into the master's tree (task 143 phase 4, master only)
2540
+ if (routePath.startsWith('/api/thread/:name/co-threads/') && req.method === 'POST') {
2541
+ const rest = routePath.replace('/api/thread/:name/co-threads/', '');
2542
+ if (rest.endsWith('/merge')) {
2543
+ const label = decodeURIComponent(rest.slice(0, -'/merge'.length));
2544
+ if (label) {
2545
+ await handleMergeCoThread(params.name, label, res);
2546
+ return;
2547
+ }
2548
+ }
2549
+ }
2230
2550
  // GET /api/thread/:name/schedules — list schedules
2231
2551
  if (routePath === '/api/thread/:name/schedules' && req.method === 'GET') {
2232
2552
  await handleListSchedules(params.name, res);