@yemi33/minions 0.1.1835 → 0.1.1836

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/dashboard.js CHANGED
@@ -1602,13 +1602,6 @@ function renderDocChatSystemPromptForTurn(turnId) {
1602
1602
  .replace(/\{\{dashboard_port\}\}/g, String(PORT));
1603
1603
  }
1604
1604
 
1605
- // Doc-chat document rewrite delimiter constants. Kept for backwards-compat
1606
- // while the system migrates to using Edit/Write tools directly (see
1607
- // prompts/cc-system.md "Document edits" section). Once CC always uses
1608
- // Edit/Write, these can be retired.
1609
- const DOC_CHAT_DOCUMENT_DELIMITER = '---MINIONS-DOC-CHAT-DOCUMENT-v1-6f2f90e3---';
1610
- const LEGACY_DOC_CHAT_DOCUMENT_DELIMITER = '---DOCUMENT---';
1611
-
1612
1605
  // Per-CC-turn correlation. CC's tool calls (Bash curl to /api/...) attach an
1613
1606
  // X-CC-Turn-Id header; the mutating endpoints record creations into this map
1614
1607
  // keyed by the turn ID so the CC handler can surface them as confirmation
@@ -2073,135 +2066,25 @@ For all state files, look under \`${MINIONS_DIR}\`.${indexSection}`;
2073
2066
  return result;
2074
2067
  }
2075
2068
 
2076
- function findCCActionsDelimiter(text) {
2077
- const header = findCCActionsHeader(text);
2078
- return header && header.parseable ? header.index : -1;
2079
- }
2080
-
2081
- // Single helper that handles both the strict (well-formed) and loose forms of
2082
- // the ===ACTIONS=== delimiter. `parseable` is true only for the strict form
2083
- // that parseCCActions can JSON.parse; loose matches still split display text
2084
- // but are not parsed (they shouldn't reach the user as actions).
2085
- function findCCActionsHeader(text) {
2086
- if (!text) return null;
2087
- // Tier 1 — strict, parseable: the exact canonical delimiter on its own line.
2088
- const strict = /(?:^|\r?\n)===ACTIONS===[ \t]*(?=\r?\n|$)/m.exec(text);
2089
- if (strict) {
2090
- const headerStart = strict.index + strict[0].indexOf('===ACTIONS');
2091
- const headerMatch = text.slice(headerStart).match(/^===ACTIONS===[ \t]*/);
2092
- return {
2093
- index: headerStart,
2094
- headerLength: headerMatch ? headerMatch[0].length : '===ACTIONS==='.length,
2095
- parseable: true,
2096
- };
2097
- }
2098
- // Tier 2 — loose: sentinel-looking malformed delimiters such as ===ACTIONS ->
2099
- // should still be hidden, but prose like "===ACTIONS are documented" must render.
2100
- const loose = /(?:^|\r?\n)===ACTIONS(?:[ \t]*(?:[-=]>?|={1,}|$)|[^A-Za-z0-9_\s\r\n][^\r\n]*)(?=\r?\n|$)/m.exec(text);
2101
- if (loose) {
2102
- const headerStart = loose.index + loose[0].indexOf('===ACTIONS');
2103
- return { index: headerStart, headerLength: 0, parseable: false };
2104
- }
2105
- // Tier 3 — very loose: 2+ leading equals, optional whitespace, ACTIONS keyword
2106
- // (case-insensitive), optional trailing equals. Catches the common model
2107
- // fumbles: ====ACTIONS===, ===actions===, ===ACTIONS=====, ==ACTIONS==. Must
2108
- // still be a complete line (preceded by line start, followed by EOL or EOS)
2109
- // so prose like "the answer is 2 + 3 = 5" or "==Important==" doesn't match.
2110
- const veryLoose = /(?:^|\r?\n)={2,}[ \t]*ACTIONS[ \t]*={0,}[ \t]*(?=\r?\n|$)/im.exec(text);
2111
- if (veryLoose) {
2112
- // Skip the leading newline (if any) so headerStart points to first '='.
2113
- const offset = veryLoose[0].search(/=/);
2114
- const headerStart = veryLoose.index + (offset >= 0 ? offset : 0);
2115
- return { index: headerStart, headerLength: 0, parseable: false };
2116
- }
2117
- return null;
2118
- }
2069
+ // The ===ACTIONS=== delimiter parser tiers (findCCActionsHeader,
2070
+ // findCCActionsPartialDelimiter, stripCCActionsForStream/Display) and the
2071
+ // _extractActionsJson Copilot fence-stripper were retired with the move to
2072
+ // direct /api/* tool calls. Action surfacing now flows entirely through the
2073
+ // X-CC-Turn-Id header → _ccTurnCreations → _buildSyntheticActionResultsForTurn.
2119
2074
 
2120
- function findCCActionsPartialDelimiter(text) {
2121
- if (!text) return -1;
2122
- const delimiter = '===ACTIONS===';
2123
- const lineStart = Math.max(text.lastIndexOf('\n'), text.lastIndexOf('\r')) + 1;
2124
- const trailingLine = text.slice(lineStart).trimEnd();
2125
- if (!trailingLine) return -1;
2126
- // Pure "=" run of any length 1+ is a likely partial of any delimiter tier.
2127
- // The next chunk arrives within milliseconds and the strip is restored or
2128
- // completed — false-positives at chunk EOL self-heal.
2129
- if (/^=+$/.test(trailingLine)) return lineStart;
2130
- // Strict prefix of the canonical "===ACTIONS===" (case-insensitive so
2131
- // "===act" and "===ACT" both strip).
2132
- if (trailingLine.length >= 1 && trailingLine.length < delimiter.length
2133
- && delimiter.toLowerCase().startsWith(trailingLine.toLowerCase())) {
2134
- return lineStart;
2135
- }
2136
- return -1;
2137
- }
2138
-
2139
- function stripCCActionsForStream(text) {
2140
- if (!text) return '';
2141
- // Fast path: 95% of streamed chunks contain no '=' before any actions block
2142
- // appears. Skip all regex work in that case.
2143
- if (text.indexOf('===') < 0) return text;
2144
- const header = findCCActionsHeader(text);
2145
- if (header) return text.slice(0, header.index).trim();
2146
- const partialIdx = findCCActionsPartialDelimiter(text);
2147
- if (partialIdx >= 0) return text.slice(0, partialIdx).trimEnd();
2148
- return text;
2149
- }
2150
-
2151
- function stripCCActionsForDisplay(text) {
2152
- if (!text) return '';
2153
- const header = findCCActionsHeader(text);
2154
- if (header) return text.slice(0, header.index).trim();
2155
- const partialIdx = findCCActionsPartialDelimiter(text);
2156
- if (partialIdx >= 0) return text.slice(0, partialIdx).trimEnd();
2157
- return text;
2158
- }
2159
-
2160
- // Issue #1834: non-Claude runtimes (Copilot/GPT) routinely wrap the action JSON
2161
- // in ```json fences or append trailing prose ("Let me know if that helps!").
2162
- // JSON.parse on the raw segment fails silently → actions dropped, user sees
2163
- // inert text. This extractor pulls out the balanced JSON value (array or
2164
- // object) regardless of fences, leading whitespace, or trailing junk so the
2165
- // downstream parse can succeed. Returns null if no plausible JSON value is
2166
- // present (caller surfaces the failure via _actionParseError).
2167
- function _extractActionsJson(segment) {
2168
- if (!segment) return null;
2169
- let body = segment.trim();
2170
- // Strip ```json / ``` fences (open + close). The model sometimes only emits
2171
- // an opening fence (truncation), so handle both halves independently.
2172
- body = body.replace(/^```[a-zA-Z0-9_-]*\s*\r?\n?/, '').replace(/\r?\n?```\s*$/, '').trim();
2173
- if (!body) return null;
2174
- const first = body.indexOf('[');
2175
- const firstObj = body.indexOf('{');
2176
- let start = -1;
2177
- let openCh = '';
2178
- let closeCh = '';
2179
- if (first >= 0 && (firstObj < 0 || first <= firstObj)) {
2180
- start = first; openCh = '['; closeCh = ']';
2181
- } else if (firstObj >= 0) {
2182
- start = firstObj; openCh = '{'; closeCh = '}';
2183
- }
2184
- if (start < 0) return null;
2185
- let depth = 0;
2186
- let inString = false;
2187
- let escape = false;
2188
- for (let i = start; i < body.length; i++) {
2189
- const ch = body[i];
2190
- if (escape) { escape = false; continue; }
2191
- if (ch === '\\') { escape = true; continue; }
2192
- if (ch === '"') { inString = !inString; continue; }
2193
- if (inString) continue;
2194
- if (ch === openCh) depth++;
2195
- else if (ch === closeCh) {
2196
- depth--;
2197
- if (depth === 0) return body.slice(start, i + 1);
2198
- }
2075
+ function normalizeMeetingParticipants(participants) {
2076
+ if (!Array.isArray(participants)) return [];
2077
+ const seen = new Set();
2078
+ const normalized = [];
2079
+ for (const participant of participants) {
2080
+ const id = String(participant || '').trim();
2081
+ if (!id || seen.has(id)) continue;
2082
+ seen.add(id);
2083
+ normalized.push(id);
2199
2084
  }
2200
- return null;
2085
+ return normalized;
2201
2086
  }
2202
2087
 
2203
- const CC_DISPATCH_ACTION_ALIASES = new Set(['fix', 'explore', 'review', 'test', 'implement', 'implement:large', 'ask', 'verify']);
2204
- const CC_ACTION_INTENT_WORK_TYPES = new Set(['fix', 'implement', 'implement:large', 'explore', 'review', 'test', 'ask', 'verify']);
2205
2088
 
2206
2089
  function getWorkItemPrRef(input) {
2207
2090
  if (!input || typeof input !== 'object') return null;
@@ -2229,1677 +2112,6 @@ function extractPrRefFromText(value) {
2229
2112
  return numberMatch ? numberMatch[1] : null;
2230
2113
  }
2231
2114
 
2232
- function normalizeCCAction(action) {
2233
- if (!action || typeof action !== 'object') return action;
2234
- if (typeof action.type !== 'string') return action;
2235
- const type = action.type.trim().toLowerCase();
2236
- if (type === 'dispatch') {
2237
- return action.type === 'dispatch' ? action : { ...action, type: 'dispatch' };
2238
- }
2239
- if (!CC_DISPATCH_ACTION_ALIASES.has(type)) return action;
2240
- return { ...action, type: 'dispatch', workType: action.workType || type };
2241
- }
2242
-
2243
- function _ccCleanIntentString(value, max = 500) {
2244
- if (typeof value !== 'string') return '';
2245
- let out = '';
2246
- let lastWasSpace = true;
2247
- for (const ch of value) {
2248
- const isSpace = ch === '\0' || ch === ' ' || ch === '\n' || ch === '\r' || ch === '\t';
2249
- if (isSpace) {
2250
- if (!lastWasSpace) out += ' ';
2251
- lastWasSpace = true;
2252
- } else {
2253
- out += ch;
2254
- lastWasSpace = false;
2255
- }
2256
- }
2257
- return out.trim().slice(0, max);
2258
- }
2259
-
2260
- function _ccNormalizeIntentMetadata(meta) {
2261
- if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return {};
2262
- const out = {};
2263
- for (const key of ['intent', 'type', 'title', 'description', 'priority', 'project', 'branchStrategy', 'branch_strategy', 'pr', 'targetPr', 'prId', 'prNumber', 'pullRequest', 'sourcePr', 'prUrl']) {
2264
- const value = _ccCleanIntentString(meta[key], key === 'description' ? 2000 : 300);
2265
- if (value) out[key] = value;
2266
- }
2267
- if (Array.isArray(meta.agents)) {
2268
- out.agents = meta.agents.map(a => _ccCleanIntentString(a, 80)).filter(Boolean);
2269
- }
2270
- if (Array.isArray(meta.projects)) {
2271
- out.projects = meta.projects.map(p => _ccCleanIntentString(p, 120)).filter(Boolean);
2272
- }
2273
- if (meta.fanout === true) out.fanout = true;
2274
- return out;
2275
- }
2276
-
2277
- function _messageExplicitlyRequestsMinionsOrchestration(message) {
2278
- const normalized = _normalizeIntentText(message);
2279
- if (!normalized.trim()) return false;
2280
- if (_messageHasDelegationIntent(message)) return true;
2281
- if (_intentHasAnyToken(normalized, ['minions']) &&
2282
- (_messageHasMediumLargeWorkIntent(message) || _messageRequestsPlanIntent(message))) return true;
2283
- if (_intentHasAnyToken(normalized, ['agent', 'agents']) && _messageHasMediumLargeWorkIntent(message)) return true;
2284
- return _intentHasVerbObject(normalized, ['create', 'make', 'draft', 'write'], ['plan']) &&
2285
- _intentHasAnyToken(normalized, ['minion', 'minions']);
2286
- }
2287
-
2288
- function _ccInferMessageActionIntent(message, source = 'command-center') {
2289
- const normalized = _normalizeIntentText(message);
2290
- if (!normalized.trim()) return null;
2291
- if (source === 'doc-chat' && !_messageExplicitlyRequestsMinionsOrchestration(message)) return null;
2292
- if (_messageRequestsPlanIntent(message)) {
2293
- return { kind: 'plan' };
2294
- }
2295
- if (!_messageHasDelegationIntent(message) && !_messageHasMediumLargeWorkIntent(message, { source })) return null;
2296
- return { kind: 'work-item', workType: _inferDelegatedWorkType(message) };
2297
- }
2298
-
2299
- function _ccInferMetadataActionIntent(meta) {
2300
- if (meta.intent === 'plan') return { kind: 'plan' };
2301
- if (meta.intent === 'work-item' && CC_ACTION_INTENT_WORK_TYPES.has(String(meta.type || '').toLowerCase())) {
2302
- return { kind: 'work-item', workType: String(meta.type).toLowerCase() };
2303
- }
2304
- return null;
2305
- }
2306
-
2307
- function _ccActionIntentTargetText(message, intent) {
2308
- const tokens = _intentTokens(_normalizeIntentText(_ccCleanIntentString(message, 500)));
2309
- let start = 0;
2310
- const skip = (terms) => {
2311
- if (terms.includes(tokens[start])) {
2312
- start++;
2313
- return true;
2314
- }
2315
- return false;
2316
- };
2317
- skip(['please']);
2318
- if (tokens[start] && tokens[start].startsWith('/')) start++;
2319
- if (['dispatch', 'delegate', 'assign', 'queue', 'enqueue'].includes(tokens[start])) {
2320
- start++;
2321
- while (['a', 'an', 'the', 'work', 'item', 'task', 'agent', 'to'].includes(tokens[start])) start++;
2322
- } else if (['create', 'open', 'file'].includes(tokens[start]) && tokens[start + 1] && ['a', 'an', 'the', 'work', 'item', 'task'].includes(tokens[start + 1])) {
2323
- start++;
2324
- while (['a', 'an', 'the', 'work', 'item', 'task', 'to', 'for'].includes(tokens[start])) start++;
2325
- } else if (['have', 'ask', 'tell'].includes(tokens[start])) {
2326
- start++;
2327
- while (tokens[start] && !['to', 'fix', 'implement', 'review', 'test', 'explore', 'investigate', 'audit'].includes(tokens[start])) start++;
2328
- if (tokens[start] === 'to') start++;
2329
- }
2330
- if (intent?.kind === 'plan') {
2331
- while (['make', 'create', 'draft', 'write', 'design', 'plan', 'out', 'a', 'an', 'the', 'for', 'to', 'about', 'how'].includes(tokens[start])) start++;
2332
- } else if (intent?.workType) {
2333
- while (['fix', 'repair', 'resolve', 'patch', 'debug', 'implement', 'build', 'add', 'create', 'ship', 'review', 'inspect', 'audit', 'test', 'verify', 'validate', 'run', 'write', 'explore', 'investigate', 'research', 'analyze', 'analyse', 'understand', 'look', 'map', 'survey', 'a', 'an', 'the', 'for', 'to', 'of', 'on', 'about'].includes(tokens[start])) start++;
2334
- }
2335
- while (['for', 'to', 'of', 'on', 'about', 'a', 'an', 'the'].includes(tokens[start])) start++;
2336
- return tokens.slice(start).join(' ');
2337
- }
2338
-
2339
- function _ccIntentHasConcreteTarget(message, metadataTitle, intent) {
2340
- const raw = String(message || '');
2341
- const normalized = _normalizeIntentText(raw);
2342
- const tokens = _intentTokens(normalized);
2343
- if (raw.includes('http://') || raw.includes('https://')) return true;
2344
- for (let i = 0; i < tokens.length; i++) {
2345
- if (['pr', 'issue'].includes(tokens[i]) && tokens[i + 1] && _intentTokenIsNumeric(tokens[i + 1])) return true;
2346
- if (tokens[i] === 'pull' && tokens[i + 1] === 'request' && tokens[i + 2] && _intentTokenIsNumeric(tokens[i + 2])) return true;
2347
- if (tokens[i] === 'w' && tokens[i + 1] && tokens[i + 1].length >= 2) return true;
2348
- }
2349
- const target = _ccActionIntentTargetText(metadataTitle || raw, intent).trim().toLowerCase();
2350
- if (!target) return false;
2351
- const targetTokens = _intentTokens(_normalizeIntentText(target));
2352
- const generic = new Set(['this', 'that', 'it', 'these', 'those', 'same', 'above', 'here', 'there', 'one', 'thing', 'issue', 'bug', 'task', 'request', 'change', 'pr', 'pull', 'code', 'test', 'plan', 'doc', 'document', 'file']);
2353
- if (targetTokens.length > 0 && targetTokens.every(token => generic.has(token))) return false;
2354
- return target.length >= 3;
2355
- }
2356
-
2357
- function _ccIntentTitle(message, metadataTitle, intent) {
2358
- const metaTitle = _ccCleanIntentString(metadataTitle, 300);
2359
- if (metaTitle && _ccIntentHasConcreteTarget(message, metaTitle, intent)) return metaTitle;
2360
- const cleaned = _ccCleanIntentString(message, 300);
2361
- if (!cleaned) return '';
2362
- const title = intent?.kind === 'plan'
2363
- ? cleaned
2364
- : _delegatedWorkTitle(cleaned, intent?.workType || 'ask');
2365
- return title.charAt(0).toUpperCase() + title.slice(1);
2366
- }
2367
-
2368
- function _ccFallbackMissingTargetError(intent) {
2369
- const label = intent?.kind === 'plan' ? 'plan' : (intent?.workType || 'dispatch');
2370
- return `Missing target for ${label} request. Specify a concrete title, PR/work item, file, or feature to ${label}.`;
2371
- }
2372
-
2373
- function _actionsWithIntentFallback(actions, opts = {}) {
2374
- const { message = '', intentMetadata = null, source = 'command-center', filePath = null, title: docTitle = null, answerText = '', toolUses = [] } = opts;
2375
- const existing = (Array.isArray(actions) ? actions.map(normalizeCCAction) : [])
2376
- .filter(action => !_ccShouldSuppressAnsweredAskDispatch(action, { message, source, answerText, toolUses }));
2377
- if (_messageRequestsDirectHandling(message, { answerText, toolUses })) return existing.filter(a => normalizeCCAction(a)?.type !== 'dispatch');
2378
- if (existing.some(a => normalizeCCAction(a)?.type === 'dispatch')) return existing;
2379
- if (existing.length > 0) return existing;
2380
- const meta = _ccNormalizeIntentMetadata(intentMetadata);
2381
- const messageIntent = _ccInferMessageActionIntent(message, source);
2382
- const metadataIntent = source === 'doc-chat' ? null : _ccInferMetadataActionIntent(meta);
2383
- const intent = messageIntent || metadataIntent;
2384
- if (!intent) return _ensureDelegationForIntent(existing, { message, source, filePath, title: docTitle, answerText, toolUses });
2385
-
2386
- const title = _ccIntentTitle(message, meta.title, intent);
2387
- const hasTarget = _ccIntentHasConcreteTarget(message, meta.title, intent);
2388
- const description = meta.description || _ccCleanIntentString(message, 2000);
2389
- const common = {
2390
- ...(title ? { title } : {}),
2391
- ...(description ? { description } : {}),
2392
- ...(meta.priority ? { priority: meta.priority } : {}),
2393
- ...(meta.project ? { project: meta.project } : {}),
2394
- ...(Array.isArray(meta.agents) && meta.agents.length ? { agents: meta.agents } : {}),
2395
- ...(meta.fanout ? { scope: 'fan-out' } : {}),
2396
- };
2397
- if (intent.kind === 'plan') {
2398
- const action = {
2399
- type: 'plan',
2400
- ...common,
2401
- branchStrategy: meta.branchStrategy || meta.branch_strategy || 'parallel',
2402
- };
2403
- if (!hasTarget) action._intentFallbackError = _ccFallbackMissingTargetError(intent);
2404
- return [action];
2405
- }
2406
- const action = {
2407
- type: 'dispatch',
2408
- workType: intent.workType,
2409
- ...common,
2410
- };
2411
- const prRef = getWorkItemPrRef(meta) || extractPrRefFromText([message, meta.title, meta.description].filter(Boolean).join('\n'));
2412
- if (prRef && isPrTargetedWorkType(intent.workType)) action.pr = prRef;
2413
- if (!hasTarget) action._intentFallbackError = _ccFallbackMissingTargetError(intent);
2414
- if (_ccShouldSuppressAnsweredAskDispatch(action, { message, source, answerText, toolUses })) return existing;
2415
- return [action];
2416
- }
2417
-
2418
- function parseCCActions(text) {
2419
- let actions = [];
2420
- let displayText = stripCCActionsForDisplay(text);
2421
- let parseError = null;
2422
- const header = findCCActionsHeader(text);
2423
- let segment = '';
2424
- if (header) {
2425
- displayText = text.slice(0, header.index).trim();
2426
- if (header.parseable) {
2427
- segment = text.slice(header.index + header.headerLength);
2428
- const jsonStr = _extractActionsJson(segment);
2429
- if (jsonStr) {
2430
- try {
2431
- const parsed = JSON.parse(jsonStr);
2432
- if (Array.isArray(parsed)) {
2433
- actions = parsed;
2434
- } else {
2435
- parseError = 'actions JSON must be an array after ===ACTIONS=== delimiter';
2436
- }
2437
- } catch (e) {
2438
- parseError = e.message || 'invalid JSON';
2439
- }
2440
- } else if (segment.trim()) {
2441
- parseError = 'no JSON value found after ===ACTIONS=== delimiter';
2442
- }
2443
- } else {
2444
- // Loose/very-loose match: delimiter present but malformed (e.g. extra
2445
- // equals, lowercase, trailing punct). Surface so the client banner fires
2446
- // instead of silently dropping actions — user resends with the strict
2447
- // shape.
2448
- parseError = 'Malformed ===ACTIONS=== delimiter (extra equals, lowercase, or trailing punctuation). Actions silently discarded — fix the model output.';
2449
- }
2450
- }
2451
- actions = actions.map(normalizeCCAction);
2452
- const result = { text: displayText, actions };
2453
- if (parseError && actions.length === 0) {
2454
- result._actionParseError = parseError;
2455
- // Visibility for the engine log — shared.log applies SEC-09 redaction before persistence.
2456
- try {
2457
- const snippet = (segment.trim() || '').slice(0, 200);
2458
- if (typeof shared !== 'undefined' && shared && typeof shared.log === 'function') {
2459
- shared.log('warn', `CC action JSON parse failed: ${parseError} — segment: ${snippet}`);
2460
- }
2461
- } catch { /* logging is best-effort */ }
2462
- }
2463
- return result;
2464
- }
2465
-
2466
- const DELEGATION_ACTION_TERMS = ['dispatch', 'delegate', 'assign', 'queue', 'enqueue'];
2467
- const DELEGATION_REQUEST_PREFIX_TERMS = ['please', 'can', 'could', 'would', 'will', 'should', 'you', 'cc', 'minions', 'minion'];
2468
- const DELEGATION_PERSON_ACTION_TERMS = ['ask', 'tell', 'have'];
2469
- // Subset of delegation action terms that double as common nouns in status
2470
- // questions (e.g. "queue status", "dispatch contents"). When followed by a
2471
- // status-noun follow-on these are noun usage, not imperative delegation.
2472
- const DELEGATION_AMBIGUOUS_NOUN_TERMS = new Set(['dispatch', 'queue']);
2473
- const DELEGATION_NON_VERB_FOLLOW_ONS = new Set([
2474
- 'status', 'contents', 'content', 'state', 'count', 'list', 'items', 'item',
2475
- 'size', 'length', 'depth', 'health', 'info', 'order', 'queue', 'history',
2476
- 'log', 'logs', 'summary', 'overview', 'report', 'is', 'was', 'are', 'were',
2477
- 'has', 'have', 'looks', 'shows',
2478
- ]);
2479
- const DELEGATION_MINIONS_PHRASES = ['have minions', 'ask minions', 'tell minions', 'hand off', 'hand it off', 'hand this off', 'send to agent', 'send it to agent', 'send this to agent'];
2480
- const MEDIUM_INVESTIGATION_TERMS = ['audit', 'investigate', 'research', 'explore', 'analyze', 'analyse'];
2481
- const MEDIUM_INVESTIGATION_PHRASES = ['deep dive', 'root cause'];
2482
- const MEDIUM_IMPLEMENT_TERMS = ['implement', 'fix', 'debug', 'repair', 'refactor', 'migrate', 'harden', 'redesign'];
2483
- const MEDIUM_BUILD_VERBS = ['build', 'add', 'create'];
2484
- const MEDIUM_BUILD_OBJECTS = ['feature', 'support', 'endpoint', 'api', 'ui', 'page', 'component', 'flow', 'integration', 'automation', 'test', 'tests', 'tool', 'command', 'dashboard', 'service', 'module'];
2485
- const MEDIUM_REVIEW_TERMS = ['review', 'test', 'verify', 'validate'];
2486
- const MEDIUM_SIZE_TERMS = ['medium', 'large', 'larger'];
2487
- const MEDIUM_SIZE_PHRASES = ['cross cutting', 'multi file', 'multi step', 'multi stage', 'multi module'];
2488
- const PLAN_INTENT_TERMS = ['plan'];
2489
- const PLAN_CREATE_TERMS = ['make', 'create', 'draft', 'write', 'design'];
2490
- const PLAN_CREATE_PHRASES = ['come up with'];
2491
- const DOC_CHAT_DIRECT_DOC_TERMS = ['summarize', 'summary', 'quote', 'extract', 'rewrite', 'reword', 'copyedit', 'proofread', 'format', 'typo'];
2492
- const DOC_CHAT_DOC_ACTION_TERMS = ['edit', 'update', 'change', 'review', 'check'];
2493
- const DOC_CHAT_DOC_OBJECTS = ['document', 'doc', 'paragraph', 'section', 'selection', 'text', 'wording'];
2494
- const DIRECT_QUICK_TERMS = ['quick', 'simple', 'small'];
2495
- const DIRECT_QUICK_OBJECTS = ['question', 'answer', 'lookup', 'check'];
2496
- const DIRECT_REPLY_TERMS = ['answer', 'respond', 'reply'];
2497
- const DIRECT_REPLY_TARGETS = ['directly', 'inline', 'here'];
2498
- const DIRECT_SELF_TERMS = ['do', 'handle', 'answer', 'fix', 'edit', 'update', 'change', 'review', 'check'];
2499
- const DIRECT_SELF_TARGETS = ['yourself', 'directly'];
2500
- const DIRECT_HANDLING_PHRASES = [
2501
- 'you do it yourself', 'you handle it yourself', 'you handle this yourself',
2502
- 'cc do it yourself', 'doc chat do it yourself', 'doc chat handle it yourself',
2503
- 'do not dispatch', 'dont dispatch', 'do not delegate', 'dont delegate',
2504
- 'do not assign', 'dont assign', 'do not queue', 'dont queue',
2505
- 'do not enqueue', 'dont enqueue', 'do not create work item', 'dont create work item',
2506
- 'no dispatch', 'no delegate', 'no delegation', 'no work item',
2507
- 'without dispatching', 'without delegating', 'without creating work item',
2508
- ];
2509
- const ANSWERED_ASK_MIN_CHARS = 80;
2510
- const ANSWERED_ASK_INCOMPLETE_PHRASES = [
2511
- 'i will dispatch', 'ill dispatch', 'i will delegate', 'ill delegate',
2512
- 'i will ask', 'ill ask', 'need to dispatch', 'needs to dispatch',
2513
- 'need to delegate', 'needs to delegate', 'need an agent', 'needs an agent',
2514
- 'hand off', 'hand this off', 'deeper investigation', 'further investigation',
2515
- 'cannot answer', 'cant answer', 'could not answer', 'couldnt answer',
2516
- 'not enough information',
2517
- ];
2518
-
2519
- function _isIntentWordChar(ch) {
2520
- const code = ch.charCodeAt(0);
2521
- return (code >= 48 && code <= 57) || (code >= 97 && code <= 122);
2522
- }
2523
-
2524
- function _normalizeIntentText(message) {
2525
- const raw = String(message || '').toLowerCase();
2526
- let out = ' ';
2527
- let lastWasSpace = true;
2528
- for (const ch of raw) {
2529
- if (ch === '\'' || ch === '\u2019') continue;
2530
- if (_isIntentWordChar(ch)) {
2531
- out += ch;
2532
- lastWasSpace = false;
2533
- } else if (!lastWasSpace) {
2534
- out += ' ';
2535
- lastWasSpace = true;
2536
- }
2537
- }
2538
- return lastWasSpace ? out : out + ' ';
2539
- }
2540
-
2541
- function _intentTokens(normalized) {
2542
- const text = String(normalized || '').trim();
2543
- return text ? text.split(' ') : [];
2544
- }
2545
-
2546
- function _intentHasAnyToken(normalized, terms) {
2547
- const tokens = new Set(_intentTokens(normalized));
2548
- return terms.some(term => tokens.has(term));
2549
- }
2550
-
2551
- function _intentHasPhrase(normalized, phrase) {
2552
- return String(normalized || '').includes(` ${phrase} `);
2553
- }
2554
-
2555
- function _intentHasAnyPhrase(normalized, phrases) {
2556
- return phrases.some(phrase => _intentHasPhrase(normalized, phrase));
2557
- }
2558
-
2559
- function _intentTokenIsNumeric(token) {
2560
- if (!token) return false;
2561
- for (const ch of String(token)) {
2562
- const code = ch.charCodeAt(0);
2563
- if (code < 48 || code > 57) return false;
2564
- }
2565
- return true;
2566
- }
2567
-
2568
- function _intentHasVerbObject(normalized, verbs, objects) {
2569
- const verbSet = new Set(verbs);
2570
- const objectSet = new Set(objects);
2571
- const filler = new Set(['a', 'an', 'the', 'this', 'that', 'it', 'new', 'proper', 'some']);
2572
- const tokens = _intentTokens(normalized);
2573
- for (let i = 0; i < tokens.length; i++) {
2574
- if (!verbSet.has(tokens[i])) continue;
2575
- for (let j = i + 1; j < tokens.length && j <= i + 4; j++) {
2576
- if (filler.has(tokens[j])) continue;
2577
- if (objectSet.has(tokens[j])) return true;
2578
- break;
2579
- }
2580
- }
2581
- return false;
2582
- }
2583
-
2584
- function _messageRequestsPlanIntent(message) {
2585
- const normalized = _normalizeIntentText(message);
2586
- const tokens = _intentTokens(normalized);
2587
- if (!tokens.length) return false;
2588
- if (tokens[0] === 'plan') return true;
2589
- if (_intentHasAnyPhrase(normalized, PLAN_CREATE_PHRASES) && _intentHasAnyToken(normalized, PLAN_INTENT_TERMS)) return true;
2590
- if (_intentHasVerbObject(normalized, PLAN_CREATE_TERMS, PLAN_INTENT_TERMS)) return true;
2591
- return _intentHasAnyToken(normalized, PLAN_INTENT_TERMS) &&
2592
- _intentHasAnyToken(normalized, ['this', 'that', 'it', 'for', 'how', 'out']);
2593
- }
2594
-
2595
- function _messageHasDelegationIntent(message) {
2596
- const normalized = _normalizeIntentText(message);
2597
- if (!normalized.trim()) return false;
2598
- if (_intentHasAnyToken(normalized, DELEGATION_ACTION_TERMS)) return true;
2599
- if (_intentHasPhrase(normalized, 'work item') && _intentHasAnyToken(normalized, ['create', 'open', 'add'])) return true;
2600
- return _intentHasAnyPhrase(normalized, DELEGATION_MINIONS_PHRASES);
2601
- }
2602
-
2603
- function _delegationTokenIsImperativeVerb(tokens, idx) {
2604
- const term = tokens[idx];
2605
- if (!term) return false;
2606
- if (!DELEGATION_AMBIGUOUS_NOUN_TERMS.has(term)) return true;
2607
- const next = tokens[idx + 1];
2608
- if (!next) return true;
2609
- return !DELEGATION_NON_VERB_FOLLOW_ONS.has(next);
2610
- }
2611
-
2612
- function _messageExplicitlyRequestsDelegation(message) {
2613
- const normalized = _normalizeIntentText(message);
2614
- const tokens = _intentTokens(normalized);
2615
- if (!tokens.length) return false;
2616
- if (_intentHasAnyPhrase(normalized, DELEGATION_MINIONS_PHRASES)) return true;
2617
- if (_intentHasPhrase(normalized, 'work item') && _intentHasAnyToken(normalized, ['create', 'open', 'add'])) return true;
2618
-
2619
- let i = 0;
2620
- while (DELEGATION_REQUEST_PREFIX_TERMS.includes(tokens[i])) i++;
2621
- if (DELEGATION_ACTION_TERMS.includes(tokens[i]) && _delegationTokenIsImperativeVerb(tokens, i)) return true;
2622
- if (DELEGATION_PERSON_ACTION_TERMS.includes(tokens[i])) {
2623
- return tokens.slice(i + 1, i + 7).includes('to');
2624
- }
2625
- for (let j = 0; j < tokens.length && j <= 6; j++) {
2626
- if (!DELEGATION_ACTION_TERMS.includes(tokens[j])) continue;
2627
- if (!_delegationTokenIsImperativeVerb(tokens, j)) continue;
2628
- const prefix = tokens.slice(0, j);
2629
- if (prefix.includes('you') || prefix.includes('minions') || prefix.includes('minion')) return true;
2630
- }
2631
- return false;
2632
- }
2633
-
2634
- function _messageRequestsDirectHandling(message, opts = {}) {
2635
- const normalized = _normalizeIntentText(message);
2636
- if (!normalized.trim()) return false;
2637
- if (_messageRequestsInformationOnly(message) && !_unansweredInvestigationSignal(opts)) return true;
2638
- if (_intentHasAnyPhrase(normalized, DIRECT_HANDLING_PHRASES)) return true;
2639
- if (_intentHasAnyToken(normalized, DIRECT_QUICK_TERMS) && _intentHasAnyToken(normalized, DIRECT_QUICK_OBJECTS)) return true;
2640
- if (_intentHasVerbObject(normalized, DIRECT_REPLY_TERMS, DIRECT_REPLY_TARGETS)) return true;
2641
- return _intentHasVerbObject(normalized, DIRECT_SELF_TERMS, DIRECT_SELF_TARGETS);
2642
- }
2643
-
2644
- function _unansweredInvestigationSignal({ answerText = '', toolUses = [] } = {}) {
2645
- const answerLen = String(answerText || '').trim().length;
2646
- const toolCount = Array.isArray(toolUses) ? toolUses.length : 0;
2647
- return toolCount >= 4 && answerLen < ANSWERED_ASK_MIN_CHARS;
2648
- }
2649
-
2650
- function _messageRequestsInformationOnly(message) {
2651
- const normalized = _normalizeIntentText(message);
2652
- const tokens = _intentTokens(normalized);
2653
- if (!tokens.length) return false;
2654
- if (['what', 'which', 'who', 'when', 'where', 'why', 'how'].includes(tokens[0])) return true;
2655
- if (['show', 'list'].includes(tokens[0])) return true;
2656
- if (tokens[0] === 'tell' && tokens[1] === 'me') return true;
2657
- if (String(message || '').trim().endsWith('?') && !_intentHasAnyToken(normalized, DELEGATION_ACTION_TERMS)) return true;
2658
- return false;
2659
- }
2660
-
2661
- function _messageIsSmallDocOnlyRequest(normalized) {
2662
- if (_intentHasAnyToken(normalized, DOC_CHAT_DIRECT_DOC_TERMS)) return true;
2663
- return _intentHasVerbObject(normalized, DOC_CHAT_DOC_ACTION_TERMS, DOC_CHAT_DOC_OBJECTS);
2664
- }
2665
-
2666
- function _messageHasMediumLargeWorkIntent(message, { source = 'command-center' } = {}) {
2667
- const normalized = _normalizeIntentText(message);
2668
- if (!normalized.trim()) return false;
2669
- if (_messageRequestsDirectHandling(message)) return false;
2670
- if (source === 'doc-chat' && _messageIsSmallDocOnlyRequest(normalized)) return false;
2671
- return _intentHasAnyToken(normalized, MEDIUM_INVESTIGATION_TERMS) ||
2672
- _intentHasAnyPhrase(normalized, MEDIUM_INVESTIGATION_PHRASES) ||
2673
- _intentHasAnyToken(normalized, MEDIUM_IMPLEMENT_TERMS) ||
2674
- _intentHasVerbObject(normalized, MEDIUM_BUILD_VERBS, MEDIUM_BUILD_OBJECTS) ||
2675
- _intentHasAnyToken(normalized, MEDIUM_REVIEW_TERMS) ||
2676
- _intentHasAnyToken(normalized, MEDIUM_SIZE_TERMS) ||
2677
- _intentHasAnyPhrase(normalized, MEDIUM_SIZE_PHRASES);
2678
- }
2679
-
2680
- function _inferDelegatedWorkType(message) {
2681
- const normalized = _normalizeIntentText(message);
2682
- if (_intentHasAnyToken(normalized, ['test', 'verify', 'validate']) || _intentHasPhrase(normalized, 'build and test')) return 'test';
2683
- if (_intentHasAnyToken(normalized, ['review']) || _intentHasPhrase(normalized, 'code review')) return 'review';
2684
- if (_intentHasAnyToken(normalized, ['fix', 'debug', 'repair', 'bug', 'broken', 'failing', 'failure', 'regression']) || _intentHasPhrase(normalized, 'root cause')) return 'fix';
2685
- if (_intentHasAnyToken(normalized, ['implement', 'refactor', 'migrate', 'harden', 'redesign']) ||
2686
- _intentHasVerbObject(normalized, MEDIUM_BUILD_VERBS, MEDIUM_BUILD_OBJECTS)) return 'implement';
2687
- if (_intentHasAnyToken(normalized, MEDIUM_INVESTIGATION_TERMS) || _intentHasAnyPhrase(normalized, MEDIUM_INVESTIGATION_PHRASES)) return 'explore';
2688
- return 'ask';
2689
- }
2690
-
2691
- function _ccAnswerLooksCompleteForAsk(answerText, toolUses = []) {
2692
- if (!Array.isArray(toolUses) || toolUses.length === 0) return false;
2693
- const answer = _collapseWhitespace(stripCCActionSyntax(answerText || ''));
2694
- if (answer.length < ANSWERED_ASK_MIN_CHARS) return false;
2695
- const normalized = _normalizeIntentText(answer);
2696
- if (_intentHasAnyPhrase(normalized, ANSWERED_ASK_INCOMPLETE_PHRASES)) return false;
2697
- if (_intentHasVerbObject(normalized, ['need', 'needs', 'require', 'requires'], ['agent', 'delegation', 'investigation', 'research'])) return false;
2698
- return true;
2699
- }
2700
-
2701
- function _ccShouldSuppressAnsweredAskDispatch(action, opts = {}) {
2702
- const normalized = normalizeCCAction(action);
2703
- if (normalized?.type !== 'dispatch') return false;
2704
- if (String(normalized.workType || '').trim().toLowerCase() !== 'ask') return false;
2705
- if (_messageExplicitlyRequestsDelegation(opts.message)) return false;
2706
- return _ccAnswerLooksCompleteForAsk(opts.answerText, opts.toolUses);
2707
- }
2708
-
2709
- function _collapseWhitespace(text) {
2710
- let out = '';
2711
- let lastWasSpace = true;
2712
- for (const ch of String(text || '')) {
2713
- const isSpace = ch === ' ' || ch === '\n' || ch === '\r' || ch === '\t';
2714
- if (isSpace) {
2715
- if (!lastWasSpace) out += ' ';
2716
- lastWasSpace = true;
2717
- } else {
2718
- out += ch;
2719
- lastWasSpace = false;
2720
- }
2721
- }
2722
- return out.trim();
2723
- }
2724
-
2725
- function _stripEdgeQuotes(text) {
2726
- const quotes = new Set(['"', "'", '`']);
2727
- let start = 0;
2728
- let end = String(text || '').length;
2729
- while (start < end && quotes.has(text[start])) start++;
2730
- while (end > start && quotes.has(text[end - 1])) end--;
2731
- return text.slice(start, end);
2732
- }
2733
-
2734
- function _delegatedWorkTitle(message, workType) {
2735
- const compact = _stripEdgeQuotes(_collapseWhitespace(String(message || '')));
2736
- const trimmed = compact.length > 90 ? compact.slice(0, 87).trimEnd() + '...' : compact;
2737
- const fallback = {
2738
- fix: 'Fix requested issue',
2739
- review: 'Review requested work',
2740
- test: 'Test requested work',
2741
- implement: 'Implement requested work',
2742
- 'implement:large': 'Implement requested work',
2743
- explore: 'Investigate requested work',
2744
- ask: 'Answer requested query',
2745
- verify: 'Verify requested work',
2746
- }[workType] || 'Handle requested work';
2747
- if (!trimmed) return fallback;
2748
- const prefix = {
2749
- fix: 'Fix',
2750
- review: 'Review',
2751
- test: 'Test',
2752
- implement: 'Implement',
2753
- 'implement:large': 'Implement',
2754
- explore: 'Investigate',
2755
- ask: 'Answer',
2756
- verify: 'Verify',
2757
- }[workType] || 'Handle';
2758
- const lower = trimmed.toLowerCase();
2759
- const prefixLower = prefix.toLowerCase();
2760
- if (lower === prefixLower || lower.startsWith(prefixLower + ' ')) return trimmed;
2761
- return `${prefix}: ${trimmed}`;
2762
- }
2763
-
2764
- function _priorityFromDelegationMessage(message) {
2765
- const normalized = _normalizeIntentText(message);
2766
- if (_intentHasAnyToken(normalized, ['urgent', 'asap', 'critical', 'blocker', 'p0', 'p1']) || _intentHasPhrase(normalized, 'high priority')) return 'high';
2767
- if (_intentHasPhrase(normalized, 'low priority') || _intentHasPhrase(normalized, 'when you can') || _intentHasAnyToken(normalized, ['whenever'])) return 'low';
2768
- return 'medium';
2769
- }
2770
-
2771
- function _inferDelegationActionFromMessage(message, { source = 'command-center', filePath = null, title = null, answerText = '', toolUses = [] } = {}) {
2772
- if (_messageRequestsDirectHandling(message, { answerText, toolUses })) return null;
2773
- const explicit = _messageHasDelegationIntent(message);
2774
- const mediumLarge = _messageHasMediumLargeWorkIntent(message, { source });
2775
- const toolCount = Array.isArray(toolUses) ? toolUses.length : 0;
2776
- if (!explicit && !mediumLarge && toolCount < 4) return null;
2777
-
2778
- const workType = _inferDelegatedWorkType(message);
2779
- const inferredProject = source === 'doc-chat' && filePath && typeof _inferDocChatProject === 'function'
2780
- ? _inferDocChatProject(filePath)
2781
- : null;
2782
- const context = [];
2783
- context.push(`${source === 'doc-chat' ? 'Doc-chat' : 'Command Center'} detected ${explicit ? 'an explicit dispatch/delegation request' : (toolCount >= 4 ? 'a medium-sized tool-using request' : 'a medium/large work request')} and routed it through the engine.`);
2784
- if (filePath) context.push(`Document: ${filePath}`);
2785
- if (title && title !== filePath) context.push(`Title: ${title}`);
2786
- if (answerText && String(answerText).trim()) context.push(`Assistant draft before delegation:\n${String(answerText).trim().slice(0, 1200)}`);
2787
- context.push(`Original request:\n${String(message || '').trim()}`);
2788
-
2789
- return {
2790
- type: 'dispatch',
2791
- title: _delegatedWorkTitle(message, workType),
2792
- workType,
2793
- priority: _priorityFromDelegationMessage(message),
2794
- description: context.join('\n\n'),
2795
- ...(inferredProject ? { project: inferredProject } : {}),
2796
- _autoDelegated: true,
2797
- };
2798
- }
2799
-
2800
- function _ensureDelegationForIntent(actions, opts = {}) {
2801
- const list = (Array.isArray(actions) ? actions.map(normalizeCCAction) : [])
2802
- .filter(action => !_ccShouldSuppressAnsweredAskDispatch(action, opts));
2803
- if (_messageRequestsDirectHandling(opts.message, opts)) return list.filter(a => normalizeCCAction(a)?.type !== 'dispatch');
2804
- if (list.some(a => normalizeCCAction(a)?.type === 'dispatch')) return list;
2805
- if (list.length > 0) return list;
2806
- const inferred = _inferDelegationActionFromMessage(opts.message, opts);
2807
- if (_ccShouldSuppressAnsweredAskDispatch(inferred, opts)) return list;
2808
- return inferred ? [...list, inferred] : list;
2809
- }
2810
-
2811
- function stripCCActionSyntax(text) {
2812
- if (!text) return '';
2813
- let displayText = text;
2814
- const header = findCCActionsHeader(text);
2815
- if (header) {
2816
- displayText = text.slice(0, header.index).trim();
2817
- } else {
2818
- // C-2: doc-chat streaming must also catch partial ===ACTIONS===
2819
- // delimiters at the chunk tail — without this, a tail like `===ACT`
2820
- // leaks raw to the modal until the next chunk completes the header.
2821
- const partialIdx = findCCActionsPartialDelimiter(displayText);
2822
- if (partialIdx >= 0) displayText = displayText.slice(0, partialIdx).trimEnd();
2823
- }
2824
- return displayText.replace(/`{3,}\s*action\s*\r?\n[\s\S]*?`{3,}\n?/g, '').trim();
2825
- }
2826
-
2827
- function _escapeRegExp(str) {
2828
- return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2829
- }
2830
-
2831
- function findLineBoundedDelimiter(text, delimiter) {
2832
- const re = new RegExp(`(?:^|\\r?\\n)${_escapeRegExp(delimiter)}[ \\t]*(?=\\r?\\n|$)`);
2833
- const match = re.exec(text || '');
2834
- if (!match) return null;
2835
- return {
2836
- index: match.index + match[0].indexOf(delimiter),
2837
- length: delimiter.length,
2838
- };
2839
- }
2840
-
2841
- function findDocChatDocumentDelimiter(text) {
2842
- return findLineBoundedDelimiter(text, DOC_CHAT_DOCUMENT_DELIMITER)
2843
- || findLineBoundedDelimiter(text, LEGACY_DOC_CHAT_DOCUMENT_DELIMITER);
2844
- }
2845
-
2846
- function markdownFenceFor(content) {
2847
- const runs = String(content || '').match(/`+/g) || [];
2848
- const maxRun = runs.reduce((max, run) => Math.max(max, run.length), 0);
2849
- return '`'.repeat(Math.max(4, maxRun + 1));
2850
- }
2851
-
2852
- function fencedUntrustedBlock(label, content) {
2853
- const value = String(content || '');
2854
- const fence = markdownFenceFor(value);
2855
- return `### ${label}\n${fence}text\n${value}\n${fence}`;
2856
- }
2857
-
2858
- function _messageExplicitlyRequestsMonitoring(message) {
2859
- const text = String(message || '').toLowerCase();
2860
- if (!text.trim()) return false;
2861
- if (/\b(?:do\s+not|don't|dont|never|without|no\s+need\s+to)\s+(?:monitor(?:ing)?|watch(?:ing)?|poll(?:ing)?|check(?:ing)?|notify(?:ing)?|ping(?:ing)?|keep(?:ing)?\s+an\s+eye)\b/.test(text)) {
2862
- return false;
2863
- }
2864
- return [
2865
- /\bmonitor(?:ing)?\b/,
2866
- /\bwatch(?:ing)?\b/,
2867
- /\bkeep(?:ing)?\s+an\s+eye\s+on\b/,
2868
- /\bcheck(?:ing)?\s+(?:it|this|that|[^.?!\n]+)\s+periodically\b/,
2869
- /\bperiodically\s+check\b/,
2870
- /\bcheck(?:ing)?\s+[^.?!\n]+\bevery\s+\d+\s*(?:s|sec|seconds?|m|min|minutes?|h|hr|hours?)\b/,
2871
- /\bevery\s+\d+\s*(?:s|sec|seconds?|m|min|minutes?|h|hr|hours?)\b[^.?!\n]+\b(?:check|poll|monitor|watch)\b/,
2872
- /\bping\s+(?:me\s+)?(?:on|when|after)\b/,
2873
- /\bping\s+on\s+completion\b/,
2874
- /\bnotify\s+(?:me\s+)?(?:on|when|after)\b/,
2875
- /\blet\s+me\s+know\s+(?:when|once|if)\b/,
2876
- /\bpoll(?:ing)?\b/,
2877
- ].some(pattern => pattern.test(text));
2878
- }
2879
-
2880
- function _isDispatchLikeCCAction(action) {
2881
- const type = String(action?.type || '').trim().toLowerCase();
2882
- return type === 'dispatch' || CC_DISPATCH_ACTION_ALIASES.has(type);
2883
- }
2884
-
2885
- function _filterImplicitPostDispatchActions(actions, humanMessage) {
2886
- if (!Array.isArray(actions) || actions.length === 0) return [];
2887
- if (_messageExplicitlyRequestsMonitoring(humanMessage)) return actions;
2888
- let sawDispatch = false;
2889
- const filtered = [];
2890
- for (const action of actions) {
2891
- if (_isDispatchLikeCCAction(action)) {
2892
- sawDispatch = true;
2893
- filtered.push(action);
2894
- continue;
2895
- }
2896
- if (!sawDispatch) filtered.push(action);
2897
- }
2898
- return filtered;
2899
- }
2900
-
2901
- // ── /loop → create-watch safety net ──────────────────────────────────────────
2902
- // CC sometimes invokes the /loop skill instead of emitting a create-watch action.
2903
- // This pure function detects /loop invocation in CC response text and synthesizes
2904
- // a create-watch action as a fallback. Returns null if no conversion needed.
2905
-
2906
- function _detectLoopInvocation(text, actions, toolUses, humanMessage) {
2907
- const observedToolUses = Array.isArray(toolUses) ? toolUses : [];
2908
- if (!text && observedToolUses.length === 0) return null;
2909
- // If a create-watch action was already emitted, no fallback needed
2910
- if (actions && actions.some(a => a.type === 'create-watch')) return null;
2911
- if (humanMessage !== undefined && !_messageExplicitlyRequestsMonitoring(humanMessage)) return null;
2912
-
2913
- function _extractTargetFromValue(value, keyHint) {
2914
- if (value == null) return null;
2915
- const hint = String(keyHint || '').toLowerCase();
2916
- if (Array.isArray(value)) {
2917
- for (const item of value) {
2918
- const nested = _extractTargetFromValue(item, hint);
2919
- if (nested) return nested;
2920
- }
2921
- return null;
2922
- }
2923
- if (typeof value === 'object') {
2924
- for (const [k, v] of Object.entries(value)) {
2925
- const nested = _extractTargetFromValue(v, k);
2926
- if (nested) return nested;
2927
- }
2928
- return null;
2929
- }
2930
- const str = String(value).trim();
2931
- if (!str) return null;
2932
- const prUrlMatch = str.match(/\/pull\/(\d+)\b/i) || str.match(/\/pullrequest\/(\d+)\b/i);
2933
- if (prUrlMatch) return { target: prUrlMatch[1], targetType: 'pr' };
2934
- const prMatch = str.match(/\bPR[- #:]?(\d+)\b/i) || str.match(/\bpull[- ]request[- #:]?(\d+)\b/i);
2935
- if (prMatch) return { target: prMatch[1], targetType: 'pr' };
2936
- const wiMatch = str.match(/\bW-([a-z0-9]+)\b/i);
2937
- if (wiMatch) return { target: 'W-' + wiMatch[1], targetType: 'work-item' };
2938
- if ((hint.includes('pr') || hint.includes('pull')) && /^\d+$/.test(str)) return { target: str, targetType: 'pr' };
2939
- if ((hint.includes('work') || hint.includes('item') || hint === 'id') && /^W-[a-z0-9]+$/i.test(str)) return { target: str.toUpperCase().startsWith('W-') ? 'W-' + str.slice(2) : str, targetType: 'work-item' };
2940
- if (hint.includes('target')) {
2941
- if (/^\d+$/.test(str)) return { target: str, targetType: 'pr' };
2942
- if (/^W-[a-z0-9]+$/i.test(str)) return { target: 'W-' + str.slice(2), targetType: 'work-item' };
2943
- }
2944
- return null;
2945
- }
2946
-
2947
- function _extractIntervalFromValue(value, keyHint) {
2948
- if (value == null) return null;
2949
- const hint = String(keyHint || '').toLowerCase();
2950
- if (Array.isArray(value)) {
2951
- for (const item of value) {
2952
- const nested = _extractIntervalFromValue(item, hint);
2953
- if (nested) return nested;
2954
- }
2955
- return null;
2956
- }
2957
- if (typeof value === 'object') {
2958
- for (const [k, v] of Object.entries(value)) {
2959
- const nested = _extractIntervalFromValue(v, k);
2960
- if (nested) return nested;
2961
- }
2962
- return null;
2963
- }
2964
- if (!(hint.includes('interval') || hint.includes('every') || hint.includes('frequency'))) return null;
2965
- const str = String(value).trim().toLowerCase();
2966
- if (!str) return null;
2967
- if (/^\d+$/.test(str)) return str;
2968
- const match = str.match(/^(\d+(?:\.\d+)?)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?)$/i);
2969
- if (!match) return null;
2970
- return match[1] + match[2][0].toLowerCase();
2971
- }
2972
-
2973
- function _extractConditionFromValue(value, keyHint) {
2974
- if (value == null) return null;
2975
- const hint = String(keyHint || '').toLowerCase();
2976
- if (Array.isArray(value)) {
2977
- for (const item of value) {
2978
- const nested = _extractConditionFromValue(item, hint);
2979
- if (nested) return nested;
2980
- }
2981
- return null;
2982
- }
2983
- if (typeof value === 'object') {
2984
- for (const [k, v] of Object.entries(value)) {
2985
- const nested = _extractConditionFromValue(v, k);
2986
- if (nested) return nested;
2987
- }
2988
- return null;
2989
- }
2990
- if (!(hint.includes('condition') || hint.includes('until') || hint.includes('goal') || hint.includes('status'))) return null;
2991
- const str = String(value).trim().toLowerCase();
2992
- if (['merged', 'build-pass', 'build-fail', 'completed', 'failed', 'status-change', 'any', 'new-comments', 'vote-change'].includes(str)) return str;
2993
- if (/\b(?:pass(?:es|ing|ed)?|green|succeed(?:s|ed)?|success)\b/i.test(str)) return 'build-pass';
2994
- if (/\b(?:fail(?:s|ing|ed)?|red|broken|broke)\b/i.test(str)) return 'build-fail';
2995
- if (/\bmerge(?:d)?\b/i.test(str)) return 'merged';
2996
- if (/\bcomplete(?:d)?\b/i.test(str)) return 'completed';
2997
- if (/\bfail(?:ed)?\b/i.test(str)) return 'failed';
2998
- if (/\bcomment/i.test(str)) return 'new-comments';
2999
- if (/\bvote|review/i.test(str)) return 'vote-change';
3000
- if (/\bstatus/i.test(str)) return 'any';
3001
- return null;
3002
- }
3003
-
3004
- const loopToolSeen = observedToolUses.some(t => /\bloop\b/i.test(String(t?.name || '')));
3005
- const toolText = observedToolUses.map(t => {
3006
- try { return [String(t?.name || ''), JSON.stringify(t?.input || {})].filter(Boolean).join(' '); }
3007
- catch { return String(t?.name || ''); }
3008
- }).join('\n');
3009
- const combinedText = [text || '', toolText].filter(Boolean).join('\n');
3010
-
3011
- // Check for /loop invocation patterns in CC response
3012
- const loopPatterns = [
3013
- /\/loop\b/i,
3014
- /\bloop skill\b/i,
3015
- /\bSkill.*\bloop\b/i,
3016
- /\bstarted.*\bloop\b/i,
3017
- /\bmonitoring.*\bloop\b/i,
3018
- /\binvok(?:e|ed|ing).*\bloop\b/i,
3019
- ];
3020
- if (!loopToolSeen && !loopPatterns.some(p => p.test(combinedText))) return null;
3021
-
3022
- // Extract target — PR number or work item ID
3023
- const directTarget = observedToolUses.map(t => _extractTargetFromValue(t && t.input, t && t.name)).find(Boolean);
3024
- const prMatch = combinedText.match(/\/pull\/(\d+)\b/i) ||
3025
- combinedText.match(/\/pullrequest\/(\d+)\b/i) ||
3026
- combinedText.match(/\bPR[- #:]?(\d+)\b/i) ||
3027
- combinedText.match(/\bpull[- ]request[- #:]?(\d+)/i);
3028
- const wiMatch = combinedText.match(/\bW-([a-z0-9]+)\b/i);
3029
-
3030
- let target = null, targetType = 'pr';
3031
- if (directTarget) {
3032
- target = directTarget.target;
3033
- targetType = directTarget.targetType;
3034
- } else if (prMatch) {
3035
- target = prMatch[1];
3036
- targetType = 'pr';
3037
- } else if (wiMatch) {
3038
- target = 'W-' + wiMatch[1];
3039
- targetType = 'work-item';
3040
- }
3041
- if (!target) return null; // Can't synthesize without a target
3042
-
3043
- // Extract interval (e.g. "every 15 minutes", "every 5m")
3044
- const directInterval = observedToolUses.map(t => _extractIntervalFromValue(t && t.input, t && t.name)).find(Boolean);
3045
- const intervalMatch = combinedText.match(/every\s+(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?)\b/i);
3046
- let interval = '5m';
3047
- if (directInterval) interval = directInterval;
3048
- else if (intervalMatch) interval = intervalMatch[1] + intervalMatch[2][0];
3049
-
3050
- // Infer condition from keywords
3051
- let condition = observedToolUses.map(t => _extractConditionFromValue(t && t.input, t && t.name)).find(Boolean) || 'any';
3052
- if (condition === 'any') {
3053
- if (/\bbuild\b/i.test(combinedText) && /\b(?:pass(?:es|ing|ed)?|green|succeed(?:s|ed)?|success)\b/i.test(combinedText)) condition = 'build-pass';
3054
- else if (/\bbuild\b/i.test(combinedText) && /\b(?:fail(?:s|ing|ed)?|red|broken|broke)\b/i.test(combinedText)) condition = 'build-fail';
3055
- else if (/\bmerge[d]?\b/i.test(combinedText)) condition = 'merged';
3056
- else if (/\bcomplete[d]?\b/i.test(combinedText)) condition = 'completed';
3057
- }
3058
-
3059
- return {
3060
- type: 'create-watch',
3061
- target,
3062
- targetType,
3063
- condition,
3064
- interval,
3065
- owner: 'human',
3066
- description: 'Auto-converted from /loop invocation',
3067
- stopAfter: condition === 'any' ? 0 : 1,
3068
- };
3069
- }
3070
-
3071
- function _extractToolUsesFromRaw(raw) {
3072
- const toolUses = [];
3073
- if (!raw) return toolUses;
3074
- for (const line of String(raw).split('\n')) {
3075
- const trimmed = line.trim();
3076
- if (!trimmed || !trimmed.startsWith('{')) continue;
3077
- try {
3078
- const obj = JSON.parse(trimmed);
3079
- if (obj.type !== 'assistant' || !Array.isArray(obj.message?.content)) continue;
3080
- for (const block of obj.message.content) {
3081
- if (block?.type === 'tool_use' && block.name) toolUses.push({ name: block.name, input: block.input || {} });
3082
- }
3083
- } catch {}
3084
- }
3085
- return toolUses;
3086
- }
3087
-
3088
- // ── Server-side CC action execution ──────────────────────────────────────────
3089
- // Actions are executed server-side so all clients (frontend, curl, Teams) get the same behavior.
3090
- // The frontend still shows status toasts but no longer needs to fire the API calls.
3091
-
3092
- // Parse interval from CC action — accepts ms number, "15m", "1h", "30s", or null (default 5m).
3093
- function _parseWatchInterval(val) {
3094
- if (!val) return 300000;
3095
- if (typeof val === 'number') return Math.max(60000, val);
3096
- const s = String(val).trim().toLowerCase();
3097
- if (/^\d+$/.test(s)) { const n = parseInt(s, 10); return Math.max(60000, n >= 1000 ? n : n * 1000); }
3098
- const m = s.match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|hours?)$/);
3099
- if (!m) return 300000;
3100
- const n = parseFloat(m[1]), u = m[2][0];
3101
- return Math.max(60000, Math.round(u === 's' ? n * 1000 : u === 'm' ? n * 60000 : n * 3600000));
3102
- }
3103
-
3104
- function normalizeMeetingParticipants(participants) {
3105
- if (!Array.isArray(participants)) return [];
3106
- const seen = new Set();
3107
- const normalized = [];
3108
- for (const participant of participants) {
3109
- const id = String(participant || '').trim();
3110
- if (!id || seen.has(id)) continue;
3111
- seen.add(id);
3112
- normalized.push(id);
3113
- }
3114
- return normalized;
3115
- }
3116
-
3117
- function meetingParticipantsFromAction(action) {
3118
- return normalizeMeetingParticipants(
3119
- Array.isArray(action?.participants) && action.participants.length > 0
3120
- ? action.participants
3121
- : action?.agents
3122
- );
3123
- }
3124
-
3125
- function normalizePipelineForCompare(pipeline) {
3126
- if (!pipeline || typeof pipeline !== 'object') return null;
3127
- return {
3128
- title: pipeline.title || '',
3129
- stages: Array.isArray(pipeline.stages) ? pipeline.stages : [],
3130
- trigger: pipeline.trigger && typeof pipeline.trigger === 'object' ? pipeline.trigger : {},
3131
- enabled: pipeline.enabled !== false,
3132
- project: pipeline.project !== undefined ? pipeline.project : null,
3133
- projects: Array.isArray(pipeline.projects) ? pipeline.projects : [],
3134
- stopWhen: pipeline.stopWhen || null,
3135
- monitoredResources: Array.isArray(pipeline.monitoredResources) ? pipeline.monitoredResources : [],
3136
- };
3137
- }
3138
-
3139
- function buildPipelineFromAction(action) {
3140
- const pipeline = {
3141
- id: String(action.id || '').trim(),
3142
- title: String(action.title || '').trim(),
3143
- stages: action.stages,
3144
- trigger: action.trigger && typeof action.trigger === 'object' ? action.trigger : {},
3145
- enabled: action.enabled !== false,
3146
- };
3147
- if (action.project !== undefined) pipeline.project = action.project;
3148
- if (Array.isArray(action.projects)) pipeline.projects = action.projects;
3149
- if (action.stopWhen) pipeline.stopWhen = action.stopWhen;
3150
- if (Array.isArray(action.monitoredResources) && action.monitoredResources.length > 0) {
3151
- pipeline.monitoredResources = action.monitoredResources;
3152
- }
3153
- return pipeline;
3154
- }
3155
-
3156
- function pipelineDefinitionsEqual(a, b) {
3157
- return JSON.stringify(normalizePipelineForCompare(a)) === JSON.stringify(normalizePipelineForCompare(b));
3158
- }
3159
-
3160
- function createPipelineFromAction(action) {
3161
- const { savePipeline, getPipeline } = require('./engine/pipeline');
3162
- const pipeline = buildPipelineFromAction(action);
3163
- const projectError = validatePipelineProjects(pipeline);
3164
- if (projectError) {
3165
- return { type: 'create-pipeline', id: pipeline.id, error: projectError };
3166
- }
3167
- const existing = getPipeline(pipeline.id);
3168
- if (existing) {
3169
- if (pipelineDefinitionsEqual(existing, pipeline)) {
3170
- return {
3171
- type: 'create-pipeline',
3172
- id: pipeline.id,
3173
- ok: true,
3174
- duplicate: true,
3175
- duplicateOf: pipeline.id,
3176
- warning: `Pipeline "${pipeline.id}" already exists; no changes made.`,
3177
- };
3178
- }
3179
- return {
3180
- type: 'create-pipeline',
3181
- id: pipeline.id,
3182
- error: `Pipeline "${pipeline.id}" already exists with a different definition. Use edit-pipeline to update it.`,
3183
- };
3184
- }
3185
- savePipeline(pipeline);
3186
- const persisted = getPipeline(pipeline.id);
3187
- if (!persisted) {
3188
- return { type: 'create-pipeline', id: pipeline.id, error: `Pipeline "${pipeline.id}" was not persisted.` };
3189
- }
3190
- if (!pipelineDefinitionsEqual(persisted, pipeline)) {
3191
- return { type: 'create-pipeline', id: pipeline.id, error: `Pipeline "${pipeline.id}" persisted with unexpected contents.` };
3192
- }
3193
- invalidateStatusCache();
3194
- return { type: 'create-pipeline', id: pipeline.id, ok: true, created: true };
3195
- }
3196
-
3197
- // Required-field validator for CC actions. Returns null when valid, an error string when not.
3198
- // Centralises field-required checks so the model can't quietly emit a malformed action and have
3199
- // the server silently fall back to placeholder values (e.g. "Untitled"). The handler invokes this
3200
- // before `try` to avoid filling `results` with cryptic per-handler error messages.
3201
- function _ccValidateAction(action) {
3202
- if (!action || typeof action !== 'object' || !action.type) return 'action is missing required field: type';
3203
- const normalized = normalizeCCAction(action);
3204
- switch (normalized.type) {
3205
- case 'dispatch':
3206
- if (!normalized.title || typeof normalized.title !== 'string' || !normalized.title.trim()) return `${action.type} action missing required field: title`;
3207
- return null;
3208
- case 'implement':
3209
- return 'Unsupported action type "implement"; use type="dispatch" with workType="implement".';
3210
- case 'build-and-test':
3211
- if (!action.pr) return 'build-and-test action missing required field: pr';
3212
- return null;
3213
- case 'note':
3214
- if (!action.title) return 'note action missing required field: title';
3215
- if (!action.content && !action.description) return 'note action missing required field: content (or description)';
3216
- return null;
3217
- case 'knowledge':
3218
- if (!action.title) return 'knowledge action missing required field: title';
3219
- if (!action.content) return 'knowledge action missing required field: content';
3220
- if (!action.category) return 'knowledge action missing required field: category';
3221
- return null;
3222
- case 'pin-to-pinned':
3223
- if (!action.title || !action.content) return 'pin-to-pinned action missing title or content';
3224
- return null;
3225
- case 'plan':
3226
- if (!action.title) return 'plan action missing required field: title';
3227
- return null;
3228
- case 'create-meeting': {
3229
- if (!action.title || typeof action.title !== 'string' || !action.title.trim()) return 'create-meeting action missing required field: title';
3230
- if (!action.agenda || typeof action.agenda !== 'string' || !action.agenda.trim()) return 'create-meeting action missing required field: agenda';
3231
- if (meetingParticipantsFromAction(action).length < 2) return 'create-meeting action requires at least 2 participants';
3232
- return null;
3233
- }
3234
- case 'create-pipeline':
3235
- if (!action.id || typeof action.id !== 'string' || !action.id.trim()) return 'create-pipeline action missing required field: id';
3236
- if (!action.title || typeof action.title !== 'string' || !action.title.trim()) return 'create-pipeline action missing required field: title';
3237
- if (!Array.isArray(action.stages) || action.stages.length === 0) return 'create-pipeline action requires non-empty stages array';
3238
- return null;
3239
- default:
3240
- return null; // unknown types fall through to existing handler / generic fallback
3241
- }
3242
- }
3243
-
3244
- let _ccLocalApiInvokerForTest = null;
3245
-
3246
- function _setCcLocalApiInvokerForTest(fn) {
3247
- _ccLocalApiInvokerForTest = typeof fn === 'function' ? fn : null;
3248
- }
3249
-
3250
- function _ccRouteMethodsForPath(pathname) {
3251
- if (!Array.isArray(_ccApiRoutesMeta) || _ccApiRoutesMeta.length === 0) return null;
3252
- const methods = new Set();
3253
- for (const route of _ccApiRoutesMeta) {
3254
- if (route._pathRegex instanceof RegExp) {
3255
- route._pathRegex.lastIndex = 0;
3256
- if (route._pathRegex.test(pathname)) methods.add(String(route.method || '').toUpperCase());
3257
- } else if (route.path === pathname) {
3258
- methods.add(String(route.method || '').toUpperCase());
3259
- }
3260
- }
3261
- return methods;
3262
- }
3263
-
3264
- function _ccValidateLocalApiFallback(endpoint, method) {
3265
- if (typeof endpoint !== 'string' || !endpoint.trim()) return 'generic API fallback requires endpoint';
3266
- const raw = endpoint.trim();
3267
- if (!(raw === '/api' || raw.startsWith('/api/'))) return 'generic API fallback endpoint must be a local /api/ path';
3268
- if (/[\0\r\n\\]/.test(raw) || raw.includes('..') || /%2e/i.test(raw) || /%5c/i.test(raw)) {
3269
- return 'generic API fallback endpoint is unsafe';
3270
- }
3271
- let parsed;
3272
- try {
3273
- parsed = new URL(raw, 'http://127.0.0.1');
3274
- } catch {
3275
- return 'generic API fallback endpoint is invalid';
3276
- }
3277
- if (parsed.origin !== 'http://127.0.0.1' || !(parsed.pathname === '/api' || parsed.pathname.startsWith('/api/'))) {
3278
- return 'generic API fallback endpoint must be a local /api/ path';
3279
- }
3280
- if (CC_API_FALLBACK_BLOCKED_PREFIXES.some(prefix => parsed.pathname === prefix || parsed.pathname.startsWith(prefix + '/'))) {
3281
- return 'generic API fallback cannot call Command Center, doc-chat, or bot endpoints';
3282
- }
3283
- if (/stream/i.test(parsed.pathname) || parsed.pathname === '/api/hot-reload') {
3284
- return 'generic API fallback cannot call streaming endpoints';
3285
- }
3286
- const normalizedMethod = String(method || 'POST').toUpperCase();
3287
- if (!CC_API_FALLBACK_METHODS.has(normalizedMethod)) {
3288
- return `generic API fallback method ${normalizedMethod} is not allowed`;
3289
- }
3290
- const routeMethods = _ccRouteMethodsForPath(parsed.pathname);
3291
- if (routeMethods && routeMethods.size > 0 && !routeMethods.has(normalizedMethod)) {
3292
- return `API endpoint ${parsed.pathname} does not allow ${normalizedMethod}; allowed methods: ${[...routeMethods].join(', ')}`;
3293
- }
3294
- if (routeMethods && routeMethods.size === 0) {
3295
- return `API endpoint ${parsed.pathname} is not in the local API index`;
3296
- }
3297
- return null;
3298
- }
3299
-
3300
- function _ccBuildQueryString(params) {
3301
- if (!params || typeof params !== 'object' || Array.isArray(params)) return '';
3302
- const search = new URLSearchParams();
3303
- for (const [key, value] of Object.entries(params)) {
3304
- if (value === undefined || value === null) continue;
3305
- if (Array.isArray(value)) {
3306
- for (const item of value) search.append(key, String(item));
3307
- } else if (typeof value === 'object') {
3308
- search.append(key, JSON.stringify(value));
3309
- } else {
3310
- search.append(key, String(value));
3311
- }
3312
- }
3313
- const text = search.toString();
3314
- return text ? '?' + text : '';
3315
- }
3316
-
3317
- function _ccRequestPath(endpoint, method, params) {
3318
- const parsed = new URL(endpoint, 'http://127.0.0.1');
3319
- if (method === 'GET') {
3320
- const extra = _ccBuildQueryString(params);
3321
- if (extra) {
3322
- const glue = parsed.search ? '&' : '?';
3323
- return parsed.pathname + parsed.search + glue + extra.slice(1);
3324
- }
3325
- }
3326
- return parsed.pathname + parsed.search;
3327
- }
3328
-
3329
- async function _ccInvokeLocalApi({ method, endpoint, params }) {
3330
- if (_ccLocalApiInvokerForTest) return _ccLocalApiInvokerForTest({ method, endpoint, params });
3331
- const requestPath = _ccRequestPath(endpoint, method, params);
3332
- return new Promise((resolve, reject) => {
3333
- const body = method === 'GET' ? null : JSON.stringify(params || {});
3334
- const req = http.request({
3335
- hostname: '127.0.0.1',
3336
- port: PORT,
3337
- method,
3338
- path: requestPath,
3339
- timeout: CC_API_FALLBACK_TIMEOUT_MS,
3340
- headers: body ? {
3341
- 'Content-Type': 'application/json',
3342
- 'Content-Length': Buffer.byteLength(body),
3343
- } : {},
3344
- }, res => {
3345
- let text = '';
3346
- res.setEncoding('utf8');
3347
- res.on('data', chunk => { text += chunk; });
3348
- res.on('end', () => {
3349
- let data = text;
3350
- try { data = text ? JSON.parse(text) : {}; } catch { /* non-JSON API response */ }
3351
- resolve({ status: res.statusCode || 0, data });
3352
- });
3353
- });
3354
- req.on('timeout', () => {
3355
- req.destroy(new Error(`local API fallback timed out after ${CC_API_FALLBACK_TIMEOUT_MS}ms`));
3356
- });
3357
- req.on('error', reject);
3358
- if (body) req.write(body);
3359
- req.end();
3360
- });
3361
- }
3362
-
3363
- function _ccApiRequest(endpoint, params = {}, method = 'POST') {
3364
- return { endpoint, params, method };
3365
- }
3366
-
3367
- function _ccMappedApiRequests(action) {
3368
- switch (action.type) {
3369
- case 'pin':
3370
- case 'pin-to-pinned':
3371
- return _ccApiRequest('/api/pinned', { title: action.title, content: action.content || action.description, level: action.level || '' });
3372
- case 'plan': {
3373
- const branchStrategy = action.branch_strategy || action.branchStrategy || 'parallel';
3374
- return _ccApiRequest('/api/plan', {
3375
- title: action.title, description: action.description || '', priority: action.priority,
3376
- project: action.project, agent: action.agent, branch_strategy: branchStrategy,
3377
- });
3378
- }
3379
- case 'cancel':
3380
- return _ccApiRequest('/api/agents/cancel', {
3381
- agent: action.agent || action.agentId,
3382
- task: action.task || action.cancelTask,
3383
- reason: action.reason || 'Cancelled via command center',
3384
- });
3385
- case 'retry':
3386
- return (action.ids || []).map(id => _ccApiRequest('/api/work-items/retry', { id, source: action.source || '' }));
3387
- case 'pause-plan':
3388
- return _ccApiRequest('/api/plans/pause', { file: action.file });
3389
- case 'approve-plan':
3390
- return _ccApiRequest('/api/plans/approve', { file: action.file });
3391
- case 'reject-plan':
3392
- return _ccApiRequest('/api/plans/reject', { file: action.file, reason: action.reason || '' });
3393
- case 'archive-plan':
3394
- return _ccApiRequest('/api/plans/archive', { file: action.file });
3395
- case 'unarchive-plan':
3396
- return _ccApiRequest('/api/plans/unarchive', { file: action.file });
3397
- case 'execute-plan':
3398
- return _ccApiRequest('/api/plans/execute', { file: action.file, project: action.project || '' });
3399
- case 'trigger-verify':
3400
- return _ccApiRequest('/api/plans/trigger-verify', { file: action.file });
3401
- case 'regenerate-plan':
3402
- return _ccApiRequest('/api/plans/approve', { file: action.file, forceRegen: true });
3403
- case 'revise-plan':
3404
- return _ccApiRequest('/api/plans/revise', { file: action.file, feedback: action.feedback || action.description, requestedBy: 'command-center' });
3405
- case 'edit-prd-item':
3406
- return _ccApiRequest('/api/prd-items/update', {
3407
- source: action.source, itemId: action.itemId, name: action.name, description: action.description,
3408
- priority: action.priority, estimated_complexity: action.estimated_complexity || action.complexity,
3409
- });
3410
- case 'remove-prd-item':
3411
- return _ccApiRequest('/api/prd-items/remove', { source: action.source, itemId: action.itemId });
3412
- case 'reopen-prd-item':
3413
- return _ccApiRequest('/api/prd-items/update', { source: action.file, itemId: action.id, status: 'updated' });
3414
- case 'delete-work-item':
3415
- return _ccApiRequest('/api/work-items/delete', { id: action.id, source: action.source || '' });
3416
- case 'cancel-work-item':
3417
- return _ccApiRequest('/api/work-items/cancel', { id: action.id, source: action.source || '', reason: action.reason || 'cc' });
3418
- case 'archive-work-item':
3419
- return _ccApiRequest('/api/work-items/archive', { id: action.id });
3420
- case 'work-item-feedback':
3421
- return _ccApiRequest('/api/work-items/feedback', { id: action.id, rating: action.rating || 'up', comment: action.comment || '' });
3422
- case 'schedule':
3423
- return _ccApiRequest(action._update ? '/api/schedules/update' : '/api/schedules', {
3424
- id: action.id, title: action.title, cron: action.cron, type: action.workType || 'implement',
3425
- project: action.project, agent: action.agent, description: action.description,
3426
- priority: action.priority, enabled: action.enabled !== false,
3427
- });
3428
- case 'delete-schedule':
3429
- return _ccApiRequest('/api/schedules/delete', { id: action.id });
3430
- case 'edit-pipeline':
3431
- return _ccApiRequest('/api/pipelines/update', {
3432
- id: action.id, title: action.title, stages: action.stages,
3433
- trigger: action.trigger, enabled: action.enabled, stopWhen: action.stopWhen,
3434
- monitoredResources: action.monitoredResources,
3435
- });
3436
- case 'delete-pipeline':
3437
- return _ccApiRequest('/api/pipelines/delete', { id: action.id });
3438
- case 'trigger-pipeline':
3439
- return _ccApiRequest('/api/pipelines/trigger', { id: action.id });
3440
- case 'continue-pipeline':
3441
- return _ccApiRequest('/api/pipelines/continue', { id: action.id, stageId: action.stageId });
3442
- case 'abort-pipeline':
3443
- return _ccApiRequest('/api/pipelines/abort', { id: action.id });
3444
- case 'retrigger-pipeline':
3445
- return _ccApiRequest('/api/pipelines/retrigger', { id: action.id });
3446
- case 'add-meeting-note':
3447
- return _ccApiRequest('/api/meetings/note', { id: action.id, note: action.note || action.content });
3448
- case 'advance-meeting':
3449
- return _ccApiRequest('/api/meetings/advance', { id: action.id });
3450
- case 'end-meeting':
3451
- return _ccApiRequest('/api/meetings/end', { id: action.id });
3452
- case 'archive-meeting':
3453
- return _ccApiRequest('/api/meetings/archive', { id: action.id });
3454
- case 'unarchive-meeting':
3455
- return _ccApiRequest('/api/meetings/unarchive', { id: action.id });
3456
- case 'delete-meeting':
3457
- return _ccApiRequest('/api/meetings/delete', { id: action.id });
3458
- case 'set-config':
3459
- return _ccApiRequest('/api/settings', { engine: { [action.setting]: action.value } });
3460
- case 'update-routing':
3461
- return _ccApiRequest('/api/settings/routing', { content: action.content });
3462
- case 'steer-agent':
3463
- return _ccApiRequest('/api/agents/steer', { agent: action.agent, message: action.message || action.content });
3464
- case 'link-pr':
3465
- return _ccApiRequest('/api/pull-requests/link', { url: action.url, title: action.title || '', project: action.project || '', autoObserve: action.autoObserve !== false });
3466
- case 'delete-pr':
3467
- return _ccApiRequest('/api/pull-requests/delete', { id: action.id, project: action.project || '' });
3468
- case 'file-bug':
3469
- return _ccApiRequest('/api/issues/create', { title: action.title, description: action.description, labels: action.labels });
3470
- case 'promote-to-kb':
3471
- return _ccApiRequest('/api/inbox/promote-kb', { name: action.file, category: action.category || 'project-notes' });
3472
- case 'kb-sweep':
3473
- return _ccApiRequest('/api/knowledge/sweep', {});
3474
- case 'toggle-kb-pin':
3475
- return _ccApiRequest('/api/kb-pins/toggle', { key: action.key });
3476
- case 'unpin':
3477
- return _ccApiRequest('/api/pinned' + '/remove', { title: action.title });
3478
- case 'add-project':
3479
- return _ccApiRequest('/api/projects/add', {
3480
- path: action.path || action.localPath, name: action.name || '',
3481
- repoHost: action.repoHost || 'github', allowNonRepo: action.allowNonRepo,
3482
- confirmToken: action.confirmToken,
3483
- });
3484
- case 'restart-engine':
3485
- return _ccApiRequest('/api/engine/restart', {});
3486
- case 'reset-settings':
3487
- return _ccApiRequest('/api/settings/reset', {});
3488
- default:
3489
- if (action.endpoint) return _ccApiRequest(action.endpoint, action.params || {}, action.method || 'POST');
3490
- return null;
3491
- }
3492
- }
3493
-
3494
- async function _ccExecuteLocalApiAction(action) {
3495
- const mapped = _ccMappedApiRequests(action);
3496
- if (!mapped) return null;
3497
- const requests = Array.isArray(mapped) ? mapped : [mapped];
3498
- if (requests.length === 0) throw new Error(`${action.type} action has no API requests to execute`);
3499
- const apiResults = [];
3500
- for (const request of requests) {
3501
- const method = String(request.method || 'POST').toUpperCase();
3502
- const endpoint = String(request.endpoint || '').trim();
3503
- const params = request.params || {};
3504
- const validationError = _ccValidateLocalApiFallback(endpoint, method);
3505
- if (validationError) throw new Error(validationError);
3506
- const response = await _ccInvokeLocalApi({ method, endpoint, params });
3507
- const status = Number(response?.status) || 0;
3508
- const data = response?.data === undefined ? {} : response.data;
3509
- if (status < 200 || status >= 300) {
3510
- const detail = data && typeof data === 'object' && data.error ? data.error : `HTTP ${status}`;
3511
- throw new Error(`${method} ${endpoint} failed: ${detail}`);
3512
- }
3513
- if (data && typeof data === 'object' && data.error) throw new Error(`${method} ${endpoint} failed: ${data.error}`);
3514
- apiResults.push({ status, data, endpoint, method });
3515
- }
3516
- const firstData = apiResults[0]?.data && typeof apiResults[0].data === 'object' ? apiResults[0].data : {};
3517
- return {
3518
- type: action.type,
3519
- ok: true,
3520
- endpoint: apiResults[0]?.endpoint,
3521
- method: apiResults[0]?.method,
3522
- status: apiResults[0]?.status,
3523
- ...(firstData.id ? { id: firstData.id } : {}),
3524
- ...(firstData.file ? { file: firstData.file } : {}),
3525
- ...(firstData.message ? { message: firstData.message } : {}),
3526
- ...(apiResults.length > 1 ? { count: apiResults.length, results: apiResults.map(r => r.data) } : { data: firstData }),
3527
- };
3528
- }
3529
-
3530
- function _ccActionFailureLabel(action) {
3531
- const type = String(action?.type || 'action').trim() || 'action';
3532
- const title = String(action?.title || action?.id || action?.file || action?.target || '').replace(/\s+/g, ' ').trim();
3533
- return title ? `${type} failed for '${title.slice(0, 120)}'` : `${type} failed`;
3534
- }
3535
-
3536
- function _ccMultiProjectRequiredError(action) {
3537
- return `${_ccActionFailureLabel(action)}: project field is required when ${PROJECTS.length} projects are configured: ${PROJECTS.map(p => p.name).join(', ')}`;
3538
- }
3539
-
3540
- async function executeCCActions(actions, { source = 'command-center', inferredProject = null } = {}) {
3541
- const results = [];
3542
- const dispatchIdsCreatedInThisCall = new Map();
3543
- for (let actionIndex = 0; actionIndex < actions.length; actionIndex++) {
3544
- const rawAction = actions[actionIndex];
3545
- const action = normalizeCCAction(rawAction);
3546
- if (action?._intentFallbackError) {
3547
- results.push({
3548
- type: action.type || 'dispatch',
3549
- ...(action.workType ? { workType: action.workType } : {}),
3550
- error: action._intentFallbackError,
3551
- missingTarget: true,
3552
- });
3553
- continue;
3554
- }
3555
- const validationError = _ccValidateAction(action);
3556
- if (validationError) {
3557
- results.push({ type: action?.type || 'unknown', error: validationError });
3558
- continue;
3559
- }
3560
- try {
3561
- switch (action.type) {
3562
- case 'dispatch': {
3563
- const workType = routing.normalizeWorkType(action.workType || (action.type !== 'dispatch' ? action.type : WORK_TYPE.IMPLEMENT), WORK_TYPE.IMPLEMENT);
3564
- const id = 'W-' + shared.uid();
3565
- const project = action.project || '';
3566
- const prTargetedWorkType = isPrTargetedWorkType(workType);
3567
- let prRef = getWorkItemPrRef(action);
3568
- let linkedPr = null;
3569
- let allPrsForAction = null;
3570
- if (!prRef && prTargetedWorkType) {
3571
- allPrsForAction = getPullRequests().filter(p => !p._ghost);
3572
- linkedPr = inferActionPrRecord(action, allPrsForAction, null);
3573
- if (linkedPr) prRef = linkedPr.id || linkedPr.url || linkedPr.prNumber;
3574
- }
3575
-
3576
- // Strict project resolution. Silent fallback to PROJECTS[0] when the model named an unknown
3577
- // project caused work items to land in the wrong repo. Now: unknown name → error; ambiguous
3578
- // (multiple projects + no field) → error; single-project deployments fall through; zero
3579
- // projects → root-level work-items.json (orchestration system standalone use).
3580
- let targetProject = null;
3581
- if (project) {
3582
- const target = resolveProjectSourceTarget(project, PROJECTS, { allowCentral: false });
3583
- targetProject = target.project;
3584
- if (target.error) {
3585
- results.push({ type: action.type, error: target.error });
3586
- break;
3587
- }
3588
- } else if (prRef) {
3589
- const allPrs = allPrsForAction || getPullRequests().filter(p => !p._ghost);
3590
- if (!linkedPr) linkedPr = shared.findPrRecord(allPrs, prRef) || null;
3591
- if (linkedPr?._project && linkedPr._project !== 'central') {
3592
- targetProject = resolveProjectSourceTarget(linkedPr._project, PROJECTS, { allowCentral: false }).project || null;
3593
- }
3594
- } else if (inferredProject) {
3595
- // Doc-chat fallback: filePath-derived project when the LLM omits the field. Validated against
3596
- // PROJECTS upstream by _inferDocChatProject — a stale lookup would just yield null here.
3597
- targetProject = resolveProjectSourceTarget(inferredProject, PROJECTS, { allowCentral: false }).project || null;
3598
- }
3599
- if (!targetProject) {
3600
- if (PROJECTS.length > 1) {
3601
- results.push({ type: action.type, error: _ccMultiProjectRequiredError(action) });
3602
- break;
3603
- }
3604
- if (PROJECTS.length === 1) targetProject = PROJECTS[0];
3605
- }
3606
- // PROJECTS.length === 0 → targetProject stays null, falls back to root work-items.json (existing behavior).
3607
-
3608
- if (targetProject && (!linkedPr || !prRef)) {
3609
- const projectPrs = shared.safeJson(shared.projectPrPath(targetProject)) || [];
3610
- shared.normalizePrRecords(projectPrs, targetProject);
3611
- if (!prRef && prTargetedWorkType) {
3612
- linkedPr = inferActionPrRecord(action, projectPrs, targetProject);
3613
- if (linkedPr) prRef = linkedPr.id || linkedPr.url || linkedPr.prNumber;
3614
- } else if (prRef && !linkedPr) {
3615
- linkedPr = shared.findPrRecord(projectPrs, prRef, targetProject) || null;
3616
- }
3617
- }
3618
- if (prRef && prTargetedWorkType && !linkedPr) {
3619
- results.push({ type: action.type, error: `PR not found: ${prRef}` });
3620
- break;
3621
- }
3622
-
3623
- const wiPath = targetProject ? shared.projectWorkItemsPath(targetProject) : shared.centralWorkItemsPath(MINIONS_DIR);
3624
-
3625
- // Promote `agent` (singular) → `agents` (array). Models emit either shape and the prior code
3626
- // only read `action.agents`, silently dropping `agent: "lambert"` style hints.
3627
- const agentHints = (() => {
3628
- if (Array.isArray(action.agents) && action.agents.length > 0) return action.agents.map(String).filter(Boolean);
3629
- if (typeof action.agent === 'string' && action.agent) return [action.agent];
3630
- return [];
3631
- })();
3632
- const knownAgents = Object.keys(CONFIG.agents || {});
3633
- const unknownAgent = agentHints.find(a => !knownAgents.includes(a));
3634
- if (unknownAgent) {
3635
- results.push({ type: action.type, error: `Unknown agent "${unknownAgent}". Configured agents: ${knownAgents.join(', ') || '(none)'}` });
3636
- break;
3637
- }
3638
-
3639
- // Issue #1772: CC review/explore/test are human-initiated one-offs.
3640
- // Mark oneShot so any discovered PR is tagged _contextOnly (skips eval loop).
3641
- const ccOneShotTypes = new Set([WORK_TYPE.REVIEW, WORK_TYPE.EXPLORE, WORK_TYPE.TEST, WORK_TYPE.ASK, WORK_TYPE.VERIFY]);
3642
- const isOneShot = action.oneShot === true || (action.oneShot !== false && ccOneShotTypes.has(workType));
3643
- const item = {
3644
- id, title: action.title.trim(), type: workType,
3645
- priority: action.priority || 'medium', description: action.description || '',
3646
- status: WI_STATUS.PENDING, created: new Date().toISOString(),
3647
- createdBy: source, project: targetProject?.name || project,
3648
- ...(action.scope ? { scope: action.scope } : {}),
3649
- ...(agentHints.length ? { preferred_agent: agentHints[0], agents: agentHints } : {}),
3650
- ...(isOneShot ? { oneShot: true } : {}),
3651
- };
3652
- copyWorkItemPrFields(item, action, linkedPr);
3653
- const createResult = createWorkItemWithDedup(wiPath, item);
3654
- if (!createResult.created) {
3655
- const duplicateId = createResult.duplicateOf || createResult.item?.id;
3656
- if (duplicateId && dispatchIdsCreatedInThisCall.has(duplicateId)) {
3657
- results.push({
3658
- type: action.type,
3659
- id: duplicateId,
3660
- ok: true,
3661
- reusedFromAction: dispatchIdsCreatedInThisCall.get(duplicateId),
3662
- });
3663
- break;
3664
- }
3665
- results.push({ type: action.type, id: duplicateId, ok: true, duplicate: true, duplicateOf: duplicateId });
3666
- break;
3667
- }
3668
- dispatchIdsCreatedInThisCall.set(id, actionIndex);
3669
- results.push({ type: action.type, id, ok: true });
3670
-
3671
- // Pre-flight routing check: warn the user if no agent is currently available so the new
3672
- // item won't sit pending invisibly. Routing failure is non-fatal — the WI was created.
3673
- try {
3674
- const resolvedAgent = routing.resolveAgent(workType, CONFIG, { agentHints, dryRun: true });
3675
- if (!resolvedAgent) {
3676
- const lastResult = results[results.length - 1];
3677
- lastResult.warning = `Created ${id} but no agent is currently available to dispatch (routing returned no match for workType=${workType}${agentHints.length ? ', hints=' + agentHints.join(',') : ''}). Item will sit pending until an agent becomes available.`;
3678
- }
3679
- } catch (e) {
3680
- shared.log('warn', `CC dispatch routing pre-flight: ${e.message}`);
3681
- }
3682
- break;
3683
- }
3684
- case 'build-and-test': {
3685
- // Resolve PR by number, ID, or URL — same lookup that drives the link-pr / PR-row paths.
3686
- const allPrs = getPullRequests().filter(p => !p._ghost);
3687
- const pr = shared.findPrRecord(allPrs, action.pr) || null;
3688
- if (!pr) {
3689
- results.push({ type: 'build-and-test', error: `PR not found: ${action.pr}` });
3690
- break;
3691
- }
3692
- // Resolve project: explicit param wins, else PR's _project. No silent first-project fallback —
3693
- // unresolved → error so build-and-test can't accidentally run against the wrong repo.
3694
- const projectName = action.project || pr._project || null;
3695
- const project = projectName
3696
- ? resolveProjectSourceTarget(projectName, PROJECTS, { allowCentral: false }).project
3697
- : null;
3698
- if (!project) {
3699
- results.push({ type: 'build-and-test', error: `Project not found for PR ${pr.id}: ${projectName || '(none)'}` });
3700
- break;
3701
- }
3702
- // Pick agent: explicit param wins; else routing for 'test' work type.
3703
- let agentId = action.agent && CONFIG.agents?.[action.agent] ? action.agent : null;
3704
- if (!agentId) {
3705
- agentId = routing.resolveAgent('test', CONFIG, { authorAgent: pr.agent });
3706
- }
3707
- if (!agentId) {
3708
- results.push({ type: 'build-and-test', error: 'No available agent for test routing' });
3709
- break;
3710
- }
3711
- const prNumber = shared.getPrNumber(pr);
3712
- const dispatchKey = `cc-bt-${project.name}-${pr.id}`;
3713
- const item = playbook.buildPrDispatch(agentId, CONFIG, project, pr, 'test', {
3714
- pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: pr.branch || '',
3715
- pr_author: pr.agent || '', pr_url: pr.url || '',
3716
- project_path: project.localPath || '',
3717
- task: `Build & test ${pr.id}: ${pr.title || ''}`,
3718
- }, `Build & test ${pr.id}: ${pr.title || ''}`,
3719
- { dispatchKey, source: 'cc-build-and-test', pr, branch: pr.branch, project: { name: project.name, localPath: project.localPath } });
3720
- if (!item) {
3721
- if (agentId?.startsWith('temp-')) {
3722
- routing.tempAgents.delete(agentId);
3723
- routing._claimedAgents.delete(agentId);
3724
- }
3725
- results.push({ type: 'build-and-test', error: 'Failed to render build-and-test playbook' });
3726
- break;
3727
- }
3728
- const id = dispatchMod.addToDispatch(item);
3729
- if (agentId?.startsWith('temp-')) {
3730
- routing.tempAgents.delete(agentId);
3731
- routing._claimedAgents.delete(agentId);
3732
- }
3733
- results.push({ type: 'build-and-test', id, agent: agentId, pr: pr.id, ok: true });
3734
- break;
3735
- }
3736
- case 'note': {
3737
- shared.writeToInbox('command-center', shared.slugify(action.title || 'note'), `# ${action.title || 'Note'}\n\n${action.content || action.description || ''}`);
3738
- results.push({ type: 'note', ok: true });
3739
- break;
3740
- }
3741
- case 'knowledge': {
3742
- const validCategories = ['architecture', 'conventions', 'project-notes', 'build-reports', 'reviews'];
3743
- const category = action.category || 'project-notes';
3744
- if (!validCategories.includes(category)) { results.push({ type: 'knowledge', error: 'Invalid category: ' + category }); break; }
3745
- const slug = shared.slugify(action.title || 'entry');
3746
- const kbDir = path.join(MINIONS_DIR, 'knowledge', category);
3747
- if (!fs.existsSync(kbDir)) fs.mkdirSync(kbDir, { recursive: true });
3748
- shared.safeWrite(path.join(kbDir, slug + '.md'), `# ${action.title}\n\n${action.content || action.description || ''}`);
3749
- queries.invalidateKnowledgeBaseCache();
3750
- results.push({ type: 'knowledge', ok: true });
3751
- break;
3752
- }
3753
- case 'reopen-work-item': {
3754
- const project = action.project || '';
3755
- let targetProject = null;
3756
- if (project) {
3757
- const target = resolveProjectSourceTarget(project, PROJECTS, { allowCentral: false });
3758
- targetProject = target.project;
3759
- if (target.error) {
3760
- results.push({ type: 'reopen-work-item', id: action.id, error: target.error });
3761
- break;
3762
- }
3763
- } else if (inferredProject) {
3764
- targetProject = resolveProjectSourceTarget(inferredProject, PROJECTS, { allowCentral: false }).project || null;
3765
- }
3766
- if (!targetProject) {
3767
- if (PROJECTS.length > 1) {
3768
- results.push({ type: 'reopen-work-item', id: action.id, error: _ccMultiProjectRequiredError({ ...action, type: 'reopen-work-item' }) });
3769
- break;
3770
- }
3771
- if (PROJECTS.length === 1) targetProject = PROJECTS[0];
3772
- }
3773
- const wiPath = targetProject ? shared.projectWorkItemsPath(targetProject) : shared.centralWorkItemsPath(MINIONS_DIR);
3774
- let reopenResult = null;
3775
- mutateJsonFileLocked(wiPath, items => {
3776
- if (!Array.isArray(items)) items = [];
3777
- const item = items.find(i => i.id === action.id);
3778
- if (!item) { reopenResult = { error: 'item not found' }; return items; }
3779
- if (item.status !== WI_STATUS.DONE && item.status !== WI_STATUS.FAILED && !DONE_STATUSES.has(item.status)) {
3780
- reopenResult = { error: 'can only reopen done or failed items' }; return items;
3781
- }
3782
- reopenWorkItem(item);
3783
- if (action.description) item.description = action.description;
3784
- reopenResult = { ok: true };
3785
- return items;
3786
- }, { defaultValue: [] });
3787
- if (reopenResult?.ok) {
3788
- // Clear dispatch history outside lock
3789
- const sourcePrefix = targetProject ? `work-${targetProject.name}-` : 'central-work-';
3790
- const dispatchKey = sourcePrefix + action.id;
3791
- try {
3792
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
3793
- mutateJsonFileLocked(dispatchPath, dispatch => {
3794
- dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
3795
- dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
3796
- dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
3797
- return dispatch;
3798
- }, { defaultValue: { pending: [], active: [], completed: [] } });
3799
- } catch { /* best effort */ }
3800
- invalidateStatusCache();
3801
- }
3802
- results.push({ type: 'reopen-work-item', id: action.id, ...(reopenResult || { error: 'unexpected' }) });
3803
- break;
3804
- }
3805
- case 'create-watch': {
3806
- const intervalMs = _parseWatchInterval(action.interval);
3807
- const watch = watchesMod.createWatch({
3808
- target: action.target,
3809
- targetType: action.targetType || 'pr',
3810
- condition: action.condition || 'build-pass',
3811
- interval: intervalMs,
3812
- owner: action.owner || 'human',
3813
- description: action.description || null,
3814
- project: action.project || null,
3815
- notify: 'inbox',
3816
- stopAfter: Number(action.stopAfter) || 0,
3817
- onNotMet: action.onNotMet || null,
3818
- });
3819
- results.push({ type: 'create-watch', id: watch.id, ok: true });
3820
- break;
3821
- }
3822
- case 'create-meeting': {
3823
- const { createMeeting } = require('./engine/meeting');
3824
- const meeting = createMeeting({
3825
- title: action.title.trim(),
3826
- agenda: action.agenda.trim(),
3827
- participants: meetingParticipantsFromAction(action),
3828
- });
3829
- invalidateStatusCache();
3830
- results.push({ type: 'create-meeting', id: meeting.id, ok: true });
3831
- break;
3832
- }
3833
- case 'create-pipeline': {
3834
- results.push(createPipelineFromAction(action));
3835
- break;
3836
- }
3837
- case 'delete-watch': {
3838
- const deleted = watchesMod.deleteWatch(action.id);
3839
- if (deleted) invalidateStatusCache();
3840
- results.push({ type: 'delete-watch', id: action.id, ok: deleted });
3841
- break;
3842
- }
3843
- case 'pause-watch': {
3844
- const paused = watchesMod.updateWatch(action.id, { status: shared.WATCH_STATUS.PAUSED });
3845
- if (paused) invalidateStatusCache();
3846
- results.push({ type: 'pause-watch', id: action.id, ok: !!paused });
3847
- break;
3848
- }
3849
- case 'resume-watch': {
3850
- const resumed = watchesMod.updateWatch(action.id, { status: shared.WATCH_STATUS.ACTIVE });
3851
- if (resumed) invalidateStatusCache();
3852
- results.push({ type: 'resume-watch', id: action.id, ok: !!resumed });
3853
- break;
3854
- }
3855
- default: {
3856
- const apiResult = await _ccExecuteLocalApiAction(action);
3857
- if (apiResult) {
3858
- results.push(apiResult);
3859
- } else {
3860
- // Server didn't handle — frontend must execute.
3861
- results.push({ type: action.type });
3862
- }
3863
- break;
3864
- }
3865
- }
3866
- } catch (e) {
3867
- results.push({ type: action.type, error: e.message });
3868
- }
3869
- }
3870
- return results;
3871
- }
3872
-
3873
- async function executeDocChatActions(actions, { filePath = null } = {}) {
3874
- if (!Array.isArray(actions) || actions.length === 0) return undefined;
3875
- return executeCCActions(actions, { source: 'doc-chat', inferredProject: _inferDocChatProject(filePath) });
3876
- }
3877
-
3878
- const DOC_CHAT_WORK_ITEM_ACTION_TYPES = new Set(['dispatch', 'fix', 'implement', 'implement:large', 'explore', 'review', 'test', 'ask', 'verify']);
3879
-
3880
- function _buildDocChatActionFeedback(actions, actionResults) {
3881
- if (!Array.isArray(actions) || !Array.isArray(actionResults)) return [];
3882
- const feedback = [];
3883
- for (let i = 0; i < actions.length && i < actionResults.length; i++) {
3884
- const action = actions[i] || {};
3885
- const result = actionResults[i] || {};
3886
- const type = String(result.type || action.type || '').trim();
3887
- if (!DOC_CHAT_WORK_ITEM_ACTION_TYPES.has(type)) continue;
3888
- if (result.error) {
3889
- feedback.push({ type, error: String(result.error) });
3890
- continue;
3891
- }
3892
- if (result.reusedFromAction !== undefined) continue;
3893
- const id = result.id || result.duplicateOf;
3894
- if (!result.ok || !id) continue;
3895
- const item = { type, id: String(id), ok: true };
3896
- if (result.duplicate) item.duplicate = true;
3897
- if (result.duplicateOf) item.duplicateOf = String(result.duplicateOf);
3898
- if (result.warning) item.warning = String(result.warning);
3899
- feedback.push(item);
3900
- }
3901
- return feedback;
3902
- }
3903
2115
 
3904
2116
  // ── Shared LLM call core — used by CC panel and doc modals ──────────────────
3905
2117
 
@@ -4318,33 +2530,11 @@ function contentFingerprint(str) {
4318
2530
  return str.length + ':' + str.charCodeAt(0) + ':' + str.charCodeAt(mid) + ':' + str.charCodeAt(str.length - 1);
4319
2531
  }
4320
2532
 
4321
- function _parseDocChatResultText(text) {
4322
- const docDelimiter = findDocChatDocumentDelimiter(text);
4323
- if (docDelimiter) {
4324
- const answerPart = text.slice(0, docDelimiter.index).trim();
4325
- const parsedActions = parseCCActions(answerPart);
4326
- const { text: answer, actions } = parsedActions;
4327
- let content = text.slice(docDelimiter.index + docDelimiter.length).trim();
4328
- content = content.replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim();
4329
- return {
4330
- answer,
4331
- content,
4332
- actions,
4333
- ...(parsedActions._actionParseError ? { actionParseError: parsedActions._actionParseError } : {}),
4334
- };
4335
- }
4336
- const parsedActions = parseCCActions(text);
4337
- const { text: stripped, actions } = parsedActions;
4338
- return {
4339
- answer: stripped,
4340
- content: null,
4341
- actions,
4342
- ...(parsedActions._actionParseError ? { actionParseError: parsedActions._actionParseError } : {}),
4343
- };
4344
- }
4345
-
2533
+ // _parseDocChatResultText / _docChatDisplayText were retired together with
2534
+ // the ---DOCUMENT--- and ===ACTIONS=== delimiters. Doc-chat now returns the
2535
+ // raw text answer; file edits flow through the Edit/Write tools.
4346
2536
  function _docChatDisplayText(text) {
4347
- return _parseDocChatResultText(text).answer;
2537
+ return String(text || '').trim();
4348
2538
  }
4349
2539
 
4350
2540
  function _inferDocChatProject(filePath) {
@@ -4368,8 +2558,9 @@ function _formatDocChatContext({ document, title, filePath, selection, canEdit,
4368
2558
  // the file path explicitly so the model knows which file Edit can target.
4369
2559
  const editInstructions = canEdit
4370
2560
  ? `\n\nIf editing is requested:\n` +
4371
- `- Prefer the runtime \`Edit\` tool against \`${filePath}\` for localized changes (typo fixes, single sections, ≲30% of the file). After Edit succeeds, just describe what you changed in plain text — do NOT also echo the document delimiter, the server reads the updated file from disk.\n` +
4372
- `- For wholesale rewrites or when an edit would invalidate JSON, fall back to the explanation followed by ${DOC_CHAT_DOCUMENT_DELIMITER} on its own line and the COMPLETE updated file. Do not use ${LEGACY_DOC_CHAT_DOCUMENT_DELIMITER} unless continuing an older session.\n` +
2561
+ `- Use the runtime \`Edit\` tool against \`${filePath}\` for localized changes (typo fixes, single sections, ≲30% of the file).\n` +
2562
+ `- Use the runtime \`Write\` tool against \`${filePath}\` for wholesale rewrites or whenever an Edit would invalidate document structure (JSON, code).\n` +
2563
+ `- After your tool calls return, just describe what you changed in plain text — the server re-reads the file from disk and refreshes the editor view. Do NOT echo the document content back, do NOT emit any delimiter (\`---DOCUMENT---\` and similar are gone).\n` +
4373
2564
  `- Never edit any file other than \`${filePath}\`.`
4374
2565
  : '\n\nRead-only — answer questions only.';
4375
2566
  let context = `## Document Context\n**${safeTitle}**${location}${isJson ? ' (JSON)' : ''}${projectHint}\n\n`;
@@ -4478,8 +2669,6 @@ function _docChatFailureResponse(label, filePath, result, sessionPreserved = fal
4478
2669
  console.error(`[${label}] Failed: code=${result.code}, errorClass=${result.errorClass || 'null'}, sessionPreserved=${sessionPreserved}, empty=${!result.text}, tools=${toolUses.length}, filePath=${filePath}, stderr=${envelope.stderr.slice(0, 200)}`);
4479
2670
  return {
4480
2671
  answer: _docChatErrorMessage(result.errorClass, sessionPreserved, toolUses, result.errorMessage || null),
4481
- content: null,
4482
- actions: [],
4483
2672
  toolUses,
4484
2673
  error: envelope,
4485
2674
  };
@@ -4493,18 +2682,11 @@ function _docChatFailureResponse(label, filePath, result, sessionPreserved = fal
4493
2682
  // Returns null when there's nothing parseable; caller falls through to failure.
4494
2683
  function _recoverPartialDocChatResponse(result, sessionKey) {
4495
2684
  if (!result || !result.text || !result.text.trim()) return null;
4496
- const parsed = _parseDocChatResultText(result.text);
4497
- const hasActions = Array.isArray(parsed.actions) && parsed.actions.length > 0;
4498
- const hasAnswer = typeof parsed.answer === 'string' && !!parsed.answer.trim();
4499
- const hasContent = typeof parsed.content === 'string' && !!parsed.content.trim();
4500
- if (!hasAnswer && !hasContent && !hasActions) return null;
4501
2685
  return {
4502
- ...parsed,
2686
+ answer: result.text,
4503
2687
  partial: true,
4504
2688
  warning: _docChatPartialWarning(result.errorClass, result.errorMessage || result.stderr || null),
4505
2689
  toolUses: Array.isArray(result.toolUses) ? result.toolUses : [],
4506
- // Recovery path still attaches the raw runtime failure — the answer landed
4507
- // despite a non-zero exit; users still benefit from seeing why.
4508
2690
  error: _buildDocChatErrorEnvelope(result),
4509
2691
  };
4510
2692
  }
@@ -4617,40 +2799,18 @@ function _finalizeDocChatEdit({ filePath, fullPath, isJson, canEdit, originalCon
4617
2799
  return { edited: true, content: diskContent };
4618
2800
  }
4619
2801
 
4620
- // Wraps the streaming onChunk so that once the document delimiter is observed
4621
- // in the growing text, subsequent chunks reuse the locked answer instead of
4622
- // re-scanning the tail. The model emits "<explanation> ---DOCUMENT--- <full file>"
4623
- // the answer portion can't grow after the delimiter, so re-parsing every
4624
- // chunk through the regenerated file body is wasted O(n²) work.
4625
- //
4626
- // Also dedups identical post-strip answers: the upstream accumulator dedups
4627
- // against the raw growing text, but that text keeps changing as the document
4628
- // body streams in even though the visible answer is locked. Without this
4629
- // guard the SSE writer fires a duplicate `chunk` event for every doc-body
4630
- // delta, which triggers a client DOM rerender and localStorage write each time.
2802
+ // Dedupes identical post-strip answers so the SSE writer doesn't fire a
2803
+ // duplicate `chunk` event for every doc-body delta when the model streams
2804
+ // `Edit`/`Write` tool input alongside narration. Since the ===ACTIONS=== /
2805
+ // ---DOCUMENT--- delimiters are gone, we just pass text through untouched
2806
+ // with a last-sent guard.
4631
2807
  function _makeDocChatStreamStripper(onChunk) {
4632
2808
  if (!onChunk) return undefined;
4633
- let lockedAnswer = null;
4634
2809
  let lastSent;
4635
2810
  return (text) => {
4636
- let answer;
4637
- if (lockedAnswer !== null) {
4638
- answer = lockedAnswer;
4639
- } else if (text.indexOf('---') < 0) {
4640
- // Fast path for typical Q&A: the doc delimiter starts with "---", so
4641
- // when the chunk doesn't contain that substring it can't possibly
4642
- // contain ---MINIONS-DOC-CHAT-DOCUMENT-…--- or ---DOCUMENT---. Skip the
4643
- // regex-heavy delimiter scan; we still need the actions stripper to
4644
- // hide partial ===ACTIONS=== while the model is mid-emission.
4645
- answer = stripCCActionsForStream(text);
4646
- } else {
4647
- const parsed = _parseDocChatResultText(text);
4648
- if (parsed.content !== null) lockedAnswer = parsed.answer;
4649
- answer = parsed.answer;
4650
- }
4651
- if (answer === lastSent) return;
4652
- lastSent = answer;
4653
- onChunk(answer);
2811
+ if (text === lastSent) return;
2812
+ lastSent = text;
2813
+ onChunk(text);
4654
2814
  };
4655
2815
  }
4656
2816
 
@@ -4717,7 +2877,12 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
4717
2877
  return _docChatFailureResponse('doc-chat', filePath, result, sessionPreserved);
4718
2878
  }
4719
2879
 
4720
- return _parseDocChatResultText(result.text);
2880
+ // No more ===ACTIONS=== / ---DOCUMENT--- parsing — CC's text answer is the
2881
+ // visible reply; file edits land via Edit/Write tools (validated in
2882
+ // _finalizeDocChatEdit by re-reading disk); state mutations land via direct
2883
+ // /api/* calls (correlated by X-CC-Turn-Id, surfaced as synthetic chips
2884
+ // server-side).
2885
+ return { answer: result.text, toolUses: Array.isArray(result.toolUses) ? result.toolUses : [] };
4721
2886
  }
4722
2887
 
4723
2888
  async function ccDocCallStreaming({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, transcript, onAbortReady, onChunk, onToolUse, onRetry }) {
@@ -4776,7 +2941,12 @@ async function ccDocCallStreaming({ message, document, title, filePath, selectio
4776
2941
  return _docChatFailureResponse('doc-chat-stream', filePath, result, sessionPreserved);
4777
2942
  }
4778
2943
 
4779
- return _parseDocChatResultText(result.text);
2944
+ // No more ===ACTIONS=== / ---DOCUMENT--- parsing — CC's text answer is the
2945
+ // visible reply; file edits land via Edit/Write tools (validated in
2946
+ // _finalizeDocChatEdit by re-reading disk); state mutations land via direct
2947
+ // /api/* calls (correlated by X-CC-Turn-Id, surfaced as synthetic chips
2948
+ // server-side).
2949
+ return { answer: result.text, toolUses: Array.isArray(result.toolUses) ? result.toolUses : [] };
4780
2950
  }
4781
2951
 
4782
2952
  // -- POST helpers --
@@ -6843,7 +5013,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6843
5013
  }
6844
5014
  }
6845
5015
 
6846
- let { answer, content, actions, actionParseError, partial, warning, toolUses, error: ccError } = await ccDocCall({
5016
+ let { answer, partial, warning, toolUses, error: ccError } = await ccDocCall({
6847
5017
  message: body.message, document: currentContent, title: body.title,
6848
5018
  filePath: body.filePath, selection: body.selection, canEdit, isJson,
6849
5019
  model: body.model || undefined,
@@ -6851,17 +5021,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6851
5021
  transcript: body.transcript,
6852
5022
  onAbortReady: (abort) => { _docAbort = abort; },
6853
5023
  });
6854
- const delegatedActions = _ensureDelegationForIntent(actions, {
6855
- message: body.message, source: 'doc-chat', filePath: body.filePath, title: body.title, answerText: answer, toolUses,
6856
- });
6857
- const actionResults = await executeDocChatActions(delegatedActions, { filePath: body.filePath });
6858
- const actionFeedback = _buildDocChatActionFeedback(delegatedActions, actionResults);
5024
+ // CC now mutates files via the Edit/Write tools and dispatches via direct
5025
+ // /api/* calls (correlated by X-CC-Turn-Id). No server-side parsing of
5026
+ // ===ACTIONS=== or ---DOCUMENT--- — _finalizeDocChatEdit just confirms
5027
+ // the file on disk changed during the turn.
6859
5028
  const finalize = _finalizeDocChatEdit({
6860
5029
  filePath: body.filePath, fullPath, isJson, canEdit,
6861
- originalContent: currentContent, delimiterContent: content,
5030
+ originalContent: currentContent, delimiterContent: null,
6862
5031
  });
6863
5032
  const payload = _buildDocChatResponsePayload({
6864
- answer, actions: delegatedActions, actionResults, actionFeedback, actionParseError, ccError, partial, warning, toolUses, finalize,
5033
+ answer, actions: [], actionResults: [], actionFeedback: null, actionParseError: null, ccError, partial, warning, toolUses, finalize,
6865
5034
  });
6866
5035
  _docDone = true;
6867
5036
  return jsonReply(res, 200, payload);
@@ -6938,7 +5107,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6938
5107
 
6939
5108
  try {
6940
5109
 
6941
- let { answer, content, actions, actionParseError, partial, warning, toolUses, error: ccError } = await ccDocCallStreaming({
5110
+ let { answer, partial, warning, toolUses, error: ccError } = await ccDocCallStreaming({
6942
5111
  message: body.message, document: currentContent, title: body.title,
6943
5112
  filePath: body.filePath, selection: body.selection, canEdit, isJson,
6944
5113
  model: body.model || undefined,
@@ -6949,17 +5118,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6949
5118
  onToolUse: (name, input) => { writeDocEvent({ type: 'tool', name, input: _lightToolInput(input) }); },
6950
5119
  onRetry: (attempt) => { writeDocEvent({ type: 'progress', attempt }); },
6951
5120
  });
6952
- const delegatedActions = _ensureDelegationForIntent(actions, {
6953
- message: body.message, source: 'doc-chat', filePath: body.filePath, title: body.title, answerText: answer, toolUses,
6954
- });
6955
- const actionResults = await executeDocChatActions(delegatedActions, { filePath: body.filePath });
6956
- const actionFeedback = _buildDocChatActionFeedback(delegatedActions, actionResults);
6957
5121
  const finalize = _finalizeDocChatEdit({
6958
5122
  filePath: body.filePath, fullPath, isJson, canEdit,
6959
- originalContent: currentContent, delimiterContent: content,
5123
+ originalContent: currentContent, delimiterContent: null,
6960
5124
  });
6961
5125
  const payload = _buildDocChatResponsePayload({
6962
- answer, actions: delegatedActions, actionResults, actionFeedback, actionParseError, ccError, partial, warning, toolUses, finalize,
5126
+ answer, actions: [], actionResults: [], actionFeedback: null, actionParseError: null, ccError, partial, warning, toolUses, finalize,
6963
5127
  });
6964
5128
  const { answer: finalAnswer, ...donePayload } = payload;
6965
5129
  writeDocEvent({
@@ -7486,45 +5650,24 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7486
5650
  });
7487
5651
  }
7488
5652
 
7489
- const toolUses = Array.isArray(result.toolUses) ? result.toolUses : _extractToolUsesFromRaw(result.raw);
7490
- const parsed = parseCCActions(result.text);
7491
- // Safety net: detect /loop invocation and convert to create-watch
7492
- const _loopWatch = _detectLoopInvocation(parsed.text, parsed.actions, toolUses, body.message);
7493
- if (_loopWatch) {
7494
- parsed.actions.push(_loopWatch);
7495
- console.warn('[CC] /loop invocation detected — converted to create-watch');
7496
- try { shared.log('warn', '/loop invocation detected in CC response — auto-converted to create-watch'); } catch {}
7497
- }
7498
- parsed.actions = _actionsWithIntentFallback(
7499
- _filterImplicitPostDispatchActions(parsed.actions, body.message),
7500
- { message: body.message, intentMetadata: body.intentMetadata, source: 'command-center', answerText: parsed.text, toolUses }
7501
- );
7502
- if (parsed.actions.length > 0) {
7503
- parsed.actionResults = await executeCCActions(parsed.actions);
7504
- parsed.actionResultsAt = new Date().toISOString();
7505
- }
7506
- // Merge synthetic action results from CC's direct /api/* tool calls
7507
- // (correlated via the X-CC-Turn-Id header). Replaces the ===ACTIONS===
7508
- // executor for the canonical "CC calls APIs directly" path while
7509
- // leaving the legacy parser available as a fallback during transition.
7510
- const _synthetic = _buildSyntheticActionResultsForTurn(ccTurnId, body.message, parsed.actionResultsAt || new Date().toISOString());
7511
- if (_synthetic.actions.length > 0) {
7512
- parsed.actions = (parsed.actions || []).concat(_synthetic.actions);
7513
- parsed.actionResults = (parsed.actionResults || []).concat(_synthetic.results);
7514
- parsed.actionResultsAt = parsed.actionResultsAt || new Date().toISOString();
7515
- }
7516
- // Mirror only user-facing text to Teams; never send the internal action block.
5653
+ // CC mutates state via direct /api/* calls (correlated via X-CC-Turn-Id);
5654
+ // any creations during this turn surface as synthetic action results
5655
+ // for the existing chip renderer.
5656
+ const _synthetic = _buildSyntheticActionResultsForTurn(ccTurnId, body.message, new Date().toISOString());
5657
+ const replyBody = {
5658
+ text: result.text,
5659
+ actions: _synthetic.actions,
5660
+ actionResults: _synthetic.results,
5661
+ ...(_synthetic.actions.length > 0 ? { actionResultsAt: new Date().toISOString() } : {}),
5662
+ sessionId: ccSession.sessionId,
5663
+ newSession: !wasResume,
5664
+ };
5665
+ // Mirror user-facing text to Teams (skip Teams-originated turns).
7517
5666
  if (!tabId.startsWith('teams-')) {
7518
- teams.teamsPostCCResponse(body.message, parsed.text).catch(() => {});
5667
+ teams.teamsPostCCResponse(body.message, result.text).catch(() => {});
7519
5668
  }
7520
- // Issue #1834: rename _actionParseError → actionParseError (public field)
7521
- // so the client can surface a warning when the model emitted ===ACTIONS===
7522
- // but the JSON couldn't be recovered.
7523
- const { _actionParseError, ...parsedReply } = parsed;
7524
- const reply = { ...parsedReply, sessionId: ccSession.sessionId, newSession: !wasResume };
7525
- if (_actionParseError) reply.actionParseError = _actionParseError;
7526
- if (sessionReset) reply.sessionReset = true;
7527
- return jsonReply(res, 200, reply);
5669
+ if (sessionReset) replyBody.sessionReset = true;
5670
+ return jsonReply(res, 200, replyBody);
7528
5671
  } finally {
7529
5672
  _releaseCCTab(tabId);
7530
5673
  }
@@ -7559,9 +5702,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7559
5702
  engineConfig,
7560
5703
  onChunk: (text) => {
7561
5704
  _touchCcLiveStream(liveState);
7562
- const display = stripCCActionsForStream(text);
7563
- liveState.text = display;
7564
- if (liveState.writer) liveState.writer({ type: 'chunk', text: display });
5705
+ liveState.text = text;
5706
+ if (liveState.writer) liveState.writer({ type: 'chunk', text });
7565
5707
  },
7566
5708
  onToolUse: (name, input) => {
7567
5709
  _touchCcLiveStream(liveState);
@@ -7835,36 +5977,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7835
5977
  } catch { /* non-critical */ }
7836
5978
  }
7837
5979
 
7838
- // Send final result with actions execute server-side first
7839
- let { text: displayText, actions, _actionParseError } = parseCCActions(result.text);
7840
- // Safety net: detect /loop invocation and convert to create-watch
7841
- const _loopWatch = _detectLoopInvocation(displayText, actions, toolUses, body.message);
7842
- if (_loopWatch) {
7843
- actions.push(_loopWatch);
7844
- console.warn('[CC] /loop invocation detected — converted to create-watch');
7845
- try { shared.log('warn', '/loop invocation detected in CC response — auto-converted to create-watch'); } catch {}
7846
- }
7847
- actions = _actionsWithIntentFallback(
7848
- _filterImplicitPostDispatchActions(actions, body.message),
7849
- { message: body.message, intentMetadata: body.intentMetadata, source: 'command-center', answerText: displayText, toolUses }
7850
- );
7851
- let actionResults;
7852
- let actionResultsAt;
7853
- if (actions.length > 0) {
7854
- actionResults = await executeCCActions(actions);
7855
- actionResultsAt = new Date().toISOString();
7856
- }
7857
- // Merge synthetic action results from CC's direct /api/* tool calls.
7858
- const _streamSynthetic = _buildSyntheticActionResultsForTurn(ccTurnId, body.message, actionResultsAt || new Date().toISOString());
7859
- if (_streamSynthetic.actions.length > 0) {
7860
- actions = (actions || []).concat(_streamSynthetic.actions);
7861
- actionResults = (actionResults || []).concat(_streamSynthetic.results);
7862
- actionResultsAt = actionResultsAt || new Date().toISOString();
7863
- }
5980
+ // CC mutates state via direct /api/* tool calls; surface those as chips.
5981
+ const displayText = result.text;
5982
+ const _streamSynthetic = _buildSyntheticActionResultsForTurn(ccTurnId, body.message, new Date().toISOString());
5983
+ const actions = _streamSynthetic.actions;
5984
+ const actionResults = _streamSynthetic.results;
5985
+ const actionResultsAt = actions.length > 0 ? new Date().toISOString() : undefined;
7864
5986
  const donePayload = { type: 'done', text: displayText, actions, actionResults, actionResultsAt, sessionId: responseSessionId, newSession: !wasResume };
7865
- // Issue #1834: surface action JSON parse failures so the UI can warn
7866
- // instead of silently dropping. Client renders this as a small notice.
7867
- if (_actionParseError) donePayload.actionParseError = _actionParseError;
7868
5987
  if (sessionReset) {
7869
5988
  donePayload.sessionReset = true;
7870
5989
  if (sessionResetReason) donePayload.sessionResetReason = sessionResetReason;
@@ -9492,12 +7611,8 @@ module.exports = {
9492
7611
  readBody,
9493
7612
  _filterCcTabSessions,
9494
7613
  _getVersionCheckInterval,
9495
- _parseWatchInterval,
9496
7614
  _normalizeMeetingParticipants: normalizeMeetingParticipants,
9497
- _meetingParticipantsFromAction: meetingParticipantsFromAction,
9498
7615
  parsePinnedEntries,
9499
- _parseDocChatResultText,
9500
- _buildDocChatActionFeedback,
9501
7616
  _formatDocChatContext,
9502
7617
  _isCompletedMeetingJson,
9503
7618
  _finalizeDocChatEdit,
@@ -9511,18 +7626,15 @@ module.exports = {
9511
7626
  _shouldSuppressDocChatPostPatchError,
9512
7627
  _buildDocChatResponsePayload,
9513
7628
  _inferDocChatProject,
9514
- _messageHasDelegationIntent,
9515
- _messageRequestsDirectHandling,
9516
- _messageHasMediumLargeWorkIntent,
9517
- _inferDelegationActionFromMessage,
9518
- _ensureDelegationForIntent,
9519
7629
  _linkPullRequestForTracking: linkPullRequestForTracking,
9520
7630
  _resolveSkillReadPath,
9521
- DOC_CHAT_DOCUMENT_DELIMITER,
9522
- _ccValidateAction,
9523
- _actionsWithIntentFallback,
9524
- _messageExplicitlyRequestsMonitoring,
9525
- _filterImplicitPostDispatchActions,
7631
+ // Per-CC-turn correlation surface (replaces ===ACTIONS=== protocol)
7632
+ _ccTurnCreations,
7633
+ _recordCcTurnCreation,
7634
+ _consumeCcTurnCreations,
7635
+ _readCcTurnIdHeader,
7636
+ _buildSyntheticActionResultsForTurn,
7637
+ CC_TURN_CREATION_TTL_MS,
9526
7638
  _findDuplicateWorkItemCreate: findDuplicateWorkItemCreate,
9527
7639
  _createWorkItemWithDedup: createWorkItemWithDedup,
9528
7640
  _resolveWorkItemsCreateTarget: resolveWorkItemsCreateTarget,
@@ -9530,12 +7642,6 @@ module.exports = {
9530
7642
  _buildManualPrdItemPlan: buildManualPrdItemPlan,
9531
7643
  _resolveScheduleProjectValue: resolveScheduleProjectValue,
9532
7644
  _collectArchivedWorkItems: collectArchivedWorkItems,
9533
- _createPipelineFromAction: createPipelineFromAction,
9534
- _setCcLocalApiInvokerForTest,
9535
- _resetCcApiRoutesMetaForTest,
9536
- _ccValidateLocalApiFallback,
9537
- executeCCActions,
9538
- executeDocChatActions,
9539
7645
  buildCCStatePreamble,
9540
7646
  _routesAsMeta,
9541
7647
  _buildTranscriptCarryover,