@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.
- package/CHANGELOG.md +20 -0
- package/dist/gateway/adapters/webchat.d.ts.map +1 -1
- package/dist/gateway/adapters/webchat.js +173 -2
- package/dist/gateway/adapters/webchat.js.map +1 -1
- package/dist/gateway/gateway-agents-mcp.js +81 -0
- package/dist/gateway/gateway-agents-mcp.js.map +1 -1
- package/dist/gateway/server.d.ts +65 -0
- package/dist/gateway/server.d.ts.map +1 -1
- package/dist/gateway/server.js +335 -15
- package/dist/gateway/server.js.map +1 -1
- package/dist/gateway/static/widget.js +581 -66
- package/dist/lib/config.d.ts +48 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +43 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/content-store.d.ts +64 -1
- package/dist/lib/content-store.d.ts.map +1 -1
- package/dist/lib/content-store.js +161 -48
- package/dist/lib/content-store.js.map +1 -1
- package/dist/lib/embeddings.d.ts +11 -1
- package/dist/lib/embeddings.d.ts.map +1 -1
- package/dist/lib/embeddings.js +301 -40
- package/dist/lib/embeddings.js.map +1 -1
- package/dist/lib/gateway.d.ts +67 -1
- package/dist/lib/gateway.d.ts.map +1 -1
- package/dist/lib/gateway.js +133 -12
- package/dist/lib/gateway.js.map +1 -1
- package/dist/lib/retriever.d.ts +34 -2
- package/dist/lib/retriever.d.ts.map +1 -1
- package/dist/lib/retriever.js +9 -2
- package/dist/lib/retriever.js.map +1 -1
- package/dist/lib/tool-inventory.d.ts.map +1 -1
- package/dist/lib/tool-inventory.js +3 -0
- package/dist/lib/tool-inventory.js.map +1 -1
- package/dist/lib/worktree.d.ts +164 -0
- package/dist/lib/worktree.d.ts.map +1 -0
- package/dist/lib/worktree.js +348 -0
- package/dist/lib/worktree.js.map +1 -0
- package/dist/mcp/index.js +7 -1
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/server.d.ts +4 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +4 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-handler.d.ts.map +1 -1
- package/dist/mcp/tool-handler.js +11 -0
- package/dist/mcp/tool-handler.js.map +1 -1
- package/package.json +1 -1
package/dist/gateway/server.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
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);
|