cc-viewer 1.8.4 → 1.8.7
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/cli.js +39 -1
- package/dist/assets/{App-CUz2vao6.js → App-QfR6J-2-.js} +2 -2
- package/dist/assets/MdxEditorPanel-BEab9H3Q.js +1 -0
- package/dist/assets/{Mobile-CEXns9fY.js → Mobile-XUIomH49.js} +1 -1
- package/dist/assets/{ProxyStatsModal-COBPefXw.js → ProxyStatsModal-Bawmf1xu.js} +1 -1
- package/dist/assets/{_baseUniq-BvoL7sea.js → _baseUniq-D2aO8mv4.js} +1 -1
- package/dist/assets/{arc-CWSgghLO.js → arc-DOjcnBTh.js} +1 -1
- package/dist/assets/architectureDiagram-Q4EWVU46-Drea2H7X.js +1 -0
- package/dist/assets/blockDiagram-DXYQGD6D-Bc9eijBD.js +1 -0
- package/dist/assets/c4Diagram-AHTNJAMY-D-umzFnM.js +1 -0
- package/dist/assets/{channel-NrrRrxlA.js → channel-DUff3FBt.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-DJvzFKwQ.js → chunk-4BX2VUAB-CFnsROfz.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-BtkeuMYF.js → chunk-4TB4RGXK-C95jMPjB.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-B9KZe48-.js → chunk-55IACEB6-CeWf2Y2b.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-CrC8aHms.js → chunk-EDXVE4YY-ClUoTJxv.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-CsMZhbHS.js → chunk-FMBD7UC4-Dk62kqb8.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-DzMU24Av.js → chunk-OYMX7WX6-CZuykSsu.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DQg8aTq0.js → chunk-QZHKN3VN-DYtx68Um.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-DiH0m9X5.js → chunk-YZCP3GAM-rLSq0tSC.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DT8RQXlO.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-DT8RQXlO.js +1 -0
- package/dist/assets/clone-_dhVVwEt.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-B5H6ewon.js +1 -0
- package/dist/assets/dagre-KV5264BT-BO2R2WP7.js +1 -0
- package/dist/assets/diagram-5BDNPKRD-Y3GNif-g.js +1 -0
- package/dist/assets/diagram-G4DWMVQ6-Du3_TylQ.js +1 -0
- package/dist/assets/diagram-MMDJMWI5-DB_Ynyjc.js +1 -0
- package/dist/assets/diagram-TYMM5635-_Rfjm82W.js +1 -0
- package/dist/assets/erDiagram-SMLLAGMA-D5XhBiiV.js +1 -0
- package/dist/assets/flowDiagram-DWJPFMVM-CxWJ7sAQ.js +1 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-D-a8fh00.js +1 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-q5WMk0Ce.js +1 -0
- package/dist/assets/{graph-BHosumIo.js → graph-Bi8xMAuo.js} +1 -1
- package/dist/assets/index-3Z3ucdlB.js +1 -0
- package/dist/assets/index-BB66PLo6.js +1 -0
- package/dist/assets/index-BBEH6Ppx.js +1 -0
- package/dist/assets/index-BRtbpMRz.js +1 -0
- package/dist/assets/index-BWPHHS6P.js +1 -0
- package/dist/assets/index-C3wsDB-y.js +1 -0
- package/dist/assets/index-O2Mx9uLd.js +1 -0
- package/dist/assets/index-gBheYuc_.js +2 -0
- package/dist/assets/infoDiagram-42DDH7IO-CE2mUuLF.js +1 -0
- package/dist/assets/ishikawaDiagram-UXIWVN3A-C5sNQIVL.js +1 -0
- package/dist/assets/journeyDiagram-VCZTEJTY-g7HXOeyX.js +1 -0
- package/dist/assets/{jszip.min-B7ufzfHi.js → jszip.min-x3xOZxY_.js} +1 -1
- package/dist/assets/kanban-definition-6JOO6SKY-D3Mus6hc.js +1 -0
- package/dist/assets/{layout-D5zEy4mK.js → layout-05qDuweB.js} +1 -1
- package/dist/assets/{linear-BAPnyqP4.js → linear-DQEG_N-F.js} +1 -1
- package/dist/assets/{mermaid.core-C8GuIPOP.js → mermaid.core-DcaXqkk5.js} +2 -2
- package/dist/assets/{min-DP92r0cl.js → min-DSpvw5zG.js} +1 -1
- package/dist/assets/mindmap-definition-QFDTVHPH-DomUIop7.js +1 -0
- package/dist/assets/pieDiagram-DEJITSTG-COTLcC5G.js +1 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-DMg8C3Up.js +1 -0
- package/dist/assets/requirementDiagram-MS252O5E-DF9beYd7.js +1 -0
- package/dist/assets/sankeyDiagram-XADWPNL6-MvERK2UP.js +1 -0
- package/dist/assets/seqResourceLoaders-KWixB6L_.js +2 -0
- package/dist/assets/{seqResourceLoaders-DI0G5nu1.css → seqResourceLoaders-vliPauH9.css} +2 -2
- package/dist/assets/sequenceDiagram-FGHM5R23-B8k_nyzc.js +1 -0
- package/dist/assets/stateDiagram-FHFEXIEX-D-9fTYas.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-CO0tv8rD.js +1 -0
- package/dist/assets/timeline-definition-GMOUNBTQ-CbK1g9CO.js +1 -0
- package/dist/assets/{vendor-antd-CSjy2pdD.js → vendor-antd-FhK5cQx7.js} +1 -1
- package/dist/assets/vendor-codemirror-DL3yyKRQ.js +1 -0
- package/dist/assets/vendor-mdxeditor-DgyrvHzS.js +2 -0
- package/dist/assets/{vendor-qrcode-Bs-eoLpy.js → vendor-qrcode-BAwwP1Nd.js} +1 -1
- package/dist/assets/vendor-virtuoso-B_7lc2Ot.js +1 -0
- package/dist/assets/vennDiagram-DHZGUBPP-DYjGtMA5.js +1 -0
- package/dist/assets/{wardley-RL74JXVD-CvCX-J_r.js → wardley-RL74JXVD-Djh5alV9.js} +1 -1
- package/dist/assets/wardleyDiagram-NUSXRM2D-al50Jlxp.js +1 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-Bl969-3U.js +1 -0
- package/dist/index.html +5 -4
- package/package.json +1 -1
- package/server/lib/code-search.js +82 -139
- package/server/lib/ensure-hooks.js +62 -5
- package/server/lib/launch-config.js +31 -0
- package/server/lib/search-worker.js +214 -0
- package/server/lib/session-start-bridge.js +5 -1
- package/server/lib/task-bridge.js +136 -0
- package/server/lib/task-state.js +150 -0
- package/server/pty-manager.js +8 -2
- package/server/routes/events.js +56 -0
- package/server/server.js +61 -0
- package/dist/assets/MdxEditorPanel-6tJrZws0.js +0 -1
- package/dist/assets/architectureDiagram-Q4EWVU46-hxmrC4g0.js +0 -1
- package/dist/assets/blockDiagram-DXYQGD6D-D3Ie-HJv.js +0 -1
- package/dist/assets/c4Diagram-AHTNJAMY-Bqi7HUr9.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DiDwaKTt.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-DiDwaKTt.js +0 -1
- package/dist/assets/clone-N9s0k-Pj.js +0 -1
- package/dist/assets/cose-bilkent-S5V4N54A-Cuw7G_qy.js +0 -1
- package/dist/assets/dagre-KV5264BT-qqKh51pl.js +0 -1
- package/dist/assets/diagram-5BDNPKRD-B-pBCkaj.js +0 -1
- package/dist/assets/diagram-G4DWMVQ6-sdTDQV0t.js +0 -1
- package/dist/assets/diagram-MMDJMWI5-CelfzJxz.js +0 -1
- package/dist/assets/diagram-TYMM5635-z0RlpFuK.js +0 -1
- package/dist/assets/erDiagram-SMLLAGMA-BHI2bnGz.js +0 -1
- package/dist/assets/flowDiagram-DWJPFMVM-I1SWP3XA.js +0 -1
- package/dist/assets/ganttDiagram-T4ZO3ILL-DZh82zoq.js +0 -1
- package/dist/assets/gitGraphDiagram-UUTBAWPF-DsMWEHvH.js +0 -1
- package/dist/assets/index-CGG82Lfj.js +0 -1
- package/dist/assets/index-CPC2RsfO.js +0 -2
- package/dist/assets/index-CiJgRlSn.js +0 -1
- package/dist/assets/index-Dn7Vc5py.js +0 -1
- package/dist/assets/index-DnekML0c.js +0 -1
- package/dist/assets/index-VFaPBr79.js +0 -1
- package/dist/assets/index-_nQUY-oI.js +0 -1
- package/dist/assets/index-s7YuKwmu.js +0 -1
- package/dist/assets/infoDiagram-42DDH7IO-DBi-2oXp.js +0 -1
- package/dist/assets/ishikawaDiagram-UXIWVN3A-fIVocwqv.js +0 -1
- package/dist/assets/journeyDiagram-VCZTEJTY-CTSi1qOI.js +0 -1
- package/dist/assets/kanban-definition-6JOO6SKY-U8kfIi3w.js +0 -1
- package/dist/assets/mindmap-definition-QFDTVHPH-CCrRqC11.js +0 -1
- package/dist/assets/pieDiagram-DEJITSTG-BrMSbuT1.js +0 -1
- package/dist/assets/quadrantDiagram-34T5L4WZ-3K-JFsjs.js +0 -1
- package/dist/assets/requirementDiagram-MS252O5E-CiqMG6uM.js +0 -1
- package/dist/assets/sankeyDiagram-XADWPNL6-CM98y9LX.js +0 -1
- package/dist/assets/seqResourceLoaders-CjHZJGi0.js +0 -2
- package/dist/assets/sequenceDiagram-FGHM5R23-C4x_1BvX.js +0 -1
- package/dist/assets/stateDiagram-FHFEXIEX-DyPisIEy.js +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-1n69gYPg.js +0 -1
- package/dist/assets/timeline-definition-GMOUNBTQ-CRnoneDr.js +0 -1
- package/dist/assets/vendor-codemirror-Clv6kvI5.js +0 -1
- package/dist/assets/vendor-mdxeditor-CtujsSUV.js +0 -2
- package/dist/assets/vendor-virtuoso-DsaYo4Fi.js +0 -1
- package/dist/assets/vennDiagram-DHZGUBPP-JQY_eM5i.js +0 -1
- package/dist/assets/wardleyDiagram-NUSXRM2D-C1ZrJXnG.js +0 -1
- package/dist/assets/xychartDiagram-5P7HB3ND-hR1VN6u6.js +0 -1
|
@@ -39,6 +39,37 @@ export function withDefaultThinkingDisplay(args) {
|
|
|
39
39
|
return hasFlag ? args : [...args, '--thinking-display', 'summarized'];
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Tokens after a literal `--` are prompt text for claude — any flag appended there is
|
|
43
|
+
// silently swallowed as part of the prompt. cc-viewer's injected flags (system-prompt
|
|
44
|
+
// file args, `--thinking-display`) must therefore land in the user-flag region BEFORE
|
|
45
|
+
// the first literal `--`. Both spawn links (PTY pty-manager / headless runProxyCommand)
|
|
46
|
+
// share this helper so the byte order stays consistent and neither path re-introduces
|
|
47
|
+
// the "flag landed after `--`" misplacement.
|
|
48
|
+
//
|
|
49
|
+
// Pure function — never mutates the input. Two steps:
|
|
50
|
+
// 1. splice `injections` in just before the first literal `--` (or at the end when absent);
|
|
51
|
+
// 2. relocate any `--thinking-display <value>` pair that ended up at/after `--`
|
|
52
|
+
// (it is appended at the very end by withDefaultThinkingDisplay) to just before `--`.
|
|
53
|
+
// User-supplied `--thinking-display` written before `--` is left untouched.
|
|
54
|
+
export function insertBeforeDashDash(args, injections = []) {
|
|
55
|
+
if (!Array.isArray(args)) return args;
|
|
56
|
+
const out = [...args];
|
|
57
|
+
const dd = () => out.indexOf('--');
|
|
58
|
+
if (injections.length) {
|
|
59
|
+
const at = dd();
|
|
60
|
+
out.splice(at === -1 ? out.length : at, 0, ...injections);
|
|
61
|
+
}
|
|
62
|
+
const live = dd();
|
|
63
|
+
if (live !== -1) {
|
|
64
|
+
const tdIdx = out.indexOf('--thinking-display', live);
|
|
65
|
+
if (tdIdx !== -1) {
|
|
66
|
+
const pair = out.splice(tdIdx, 2); // ['--thinking-display', value]
|
|
67
|
+
out.splice(out.indexOf('--'), 0, ...pair);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
42
73
|
// Parse continuation flags from claude args: -c/--continue, -r/--resume (value as the
|
|
43
74
|
// next token or in = form), --fork-session. Returns null for non-continuation launches;
|
|
44
75
|
// picker=true means a valueless -r (interactive picker — target unknowable at spawn).
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Search worker — runs the pure-node fallback engine OFF the server's event loop.
|
|
2
|
+
//
|
|
3
|
+
// Why: the node engine does per-file sync fs (lstat/realpath/stat/read up to 1MB) plus a JS
|
|
4
|
+
// RegExp over every line. On the main loop that pins the whole server (in Electron each tab's
|
|
5
|
+
// server is a forked process, so a scan would freeze its SSE/WS/PTY traffic). Inside a
|
|
6
|
+
// worker the sync calls only block the worker's own loop — harmless.
|
|
7
|
+
//
|
|
8
|
+
// Protocol (one job per Worker instance; the parent terminates on completion/abort):
|
|
9
|
+
// in : workerData { root, query, caseSensitive, wholeWord, regex, includeGlobs, excludeGlobs,
|
|
10
|
+
// maxResults, maxMatchesPerFile, maxFileSize, nodeTimeBudgetMs }
|
|
11
|
+
// out: parentPort.postMessage({ type: 'done', results, truncated, filesScanned })
|
|
12
|
+
// parentPort.postMessage({ type: 'error', error: 'invalid_regex' | 'search_failed' })
|
|
13
|
+
//
|
|
14
|
+
// Environment note: this file is imported by node --test in the parent process for direct
|
|
15
|
+
// runNodeScan unit coverage, so everything executes lazily inside the `if (parentPort)` guard.
|
|
16
|
+
import { isMainThread, parentPort, workerData } from 'node:worker_threads';
|
|
17
|
+
import { readFileSync, readdirSync, lstatSync, statSync, realpathSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { spawn } from 'node:child_process';
|
|
20
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
21
|
+
import { isReadAllowed } from './file-access-policy.js';
|
|
22
|
+
import {
|
|
23
|
+
buildQueryRegExp, looksCatastrophic, globToRegExp,
|
|
24
|
+
isHidden, hasIgnoredSegment, normRel, isBinary, IGNORED_NAMES, DEFAULTS,
|
|
25
|
+
} from './code-search.js';
|
|
26
|
+
|
|
27
|
+
// Kill a hung `git ls-files` (index.lock contention, slow/NFS mount). Unrelated to the scan
|
|
28
|
+
// time budget.
|
|
29
|
+
const GIT_LS_FILES_TIMEOUT_MS = 8000;
|
|
30
|
+
|
|
31
|
+
// Yield cadence: every FILE_YIELD_INTERVAL candidates AND every BYTE_YIELD_BUDGET bytes read,
|
|
32
|
+
// whichever comes first — a few 1MB files must not starve the worker's own loop between the
|
|
33
|
+
// 64-file ticks (the budget keeps postMessage/terminate latency low).
|
|
34
|
+
const FILE_YIELD_INTERVAL = 64;
|
|
35
|
+
const BYTE_YIELD_BUDGET = 4 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
function makeGlobFilter(includeGlobs, excludeGlobs) {
|
|
38
|
+
// Parity with rg: a '!'-prefixed entry in the include field is a negation (exclude),
|
|
39
|
+
// mirroring rg's `-g !glob`. globToRegExp strips the leading '!'.
|
|
40
|
+
const inc = [];
|
|
41
|
+
const exc = [];
|
|
42
|
+
for (const g of includeGlobs || []) { if (!g) continue; (g.startsWith('!') ? exc : inc).push(globToRegExp(g)); }
|
|
43
|
+
for (const g of excludeGlobs || []) { if (!g) continue; exc.push(globToRegExp(g)); }
|
|
44
|
+
return (relPath) => {
|
|
45
|
+
if (inc.length && !inc.some((r) => r.test(relPath))) return false;
|
|
46
|
+
if (exc.some((r) => r.test(relPath))) return false;
|
|
47
|
+
return true;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createGrouper(maxResults, maxMatchesPerFile) {
|
|
52
|
+
const byFile = new Map();
|
|
53
|
+
let total = 0;
|
|
54
|
+
let capped = false; // a per-file or global cap dropped some matches
|
|
55
|
+
return {
|
|
56
|
+
/** @returns {boolean} true if still under the global cap, false once maxResults is hit */
|
|
57
|
+
add(file, match) {
|
|
58
|
+
if (total >= maxResults) { capped = true; return false; }
|
|
59
|
+
let entry = byFile.get(file);
|
|
60
|
+
if (!entry) { entry = []; byFile.set(file, entry); }
|
|
61
|
+
if (entry.length >= maxMatchesPerFile) { capped = true; return true; }
|
|
62
|
+
entry.push(match);
|
|
63
|
+
total++;
|
|
64
|
+
return total < maxResults;
|
|
65
|
+
},
|
|
66
|
+
get total() { return total; },
|
|
67
|
+
get capped() { return capped; },
|
|
68
|
+
results() {
|
|
69
|
+
return [...byFile.entries()].map(([file, matches]) => ({ file, matches }));
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function gitListFiles(root) {
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
let child;
|
|
77
|
+
try {
|
|
78
|
+
child = spawn('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'],
|
|
79
|
+
{ cwd: root, windowsHide: true });
|
|
80
|
+
} catch { resolve(null); return; }
|
|
81
|
+
let out = '';
|
|
82
|
+
const decoder = new StringDecoder('utf8');
|
|
83
|
+
// Bound a hung git (index.lock contention, slow/NFS mount) so the scan always resolves.
|
|
84
|
+
const timer = setTimeout(() => { try { child.kill(); } catch { /* gone */ } }, GIT_LS_FILES_TIMEOUT_MS);
|
|
85
|
+
child.stdout.on('data', (d) => { out += decoder.write(d); });
|
|
86
|
+
child.stdout.on('error', () => {});
|
|
87
|
+
child.on('error', () => { clearTimeout(timer); resolve(null); });
|
|
88
|
+
child.on('close', (code) => {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
out += decoder.end();
|
|
91
|
+
if (code !== 0) { resolve(null); return; }
|
|
92
|
+
resolve(out.split('\0').filter(Boolean));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function walkDir(root) {
|
|
98
|
+
const out = [];
|
|
99
|
+
const stack = [''];
|
|
100
|
+
while (stack.length) {
|
|
101
|
+
const rel = stack.pop();
|
|
102
|
+
let entries;
|
|
103
|
+
try { entries = readdirSync(rel ? join(root, rel) : root, { withFileTypes: true }); }
|
|
104
|
+
catch { continue; }
|
|
105
|
+
for (const e of entries) {
|
|
106
|
+
if (e.name.startsWith('.') || IGNORED_NAMES.has(e.name)) continue;
|
|
107
|
+
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
|
108
|
+
if (e.isSymbolicLink()) continue; // don't follow symlinks (parity + safety)
|
|
109
|
+
if (e.isDirectory()) stack.push(childRel);
|
|
110
|
+
else if (e.isFile()) out.push(childRel);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The full node-engine scan (former nodeSearch body, semantics unchanged).
|
|
118
|
+
* No AbortSignal input: cancellation is the parent terminating the worker. The cooperative
|
|
119
|
+
* time-budget/cap exits are unchanged from the in-process implementation.
|
|
120
|
+
* Exported for direct unit coverage (node --test runs this file with isMainThread=true).
|
|
121
|
+
*/
|
|
122
|
+
export async function runNodeScan(opts) {
|
|
123
|
+
// Catastrophic-backtracking guard applies ONLY to the JS-backtracking node engine (ripgrep's
|
|
124
|
+
// engine is linear and safe), so it lives here rather than gating the rg path too.
|
|
125
|
+
if (opts.regex && looksCatastrophic(opts.query)) {
|
|
126
|
+
return { results: [], truncated: false, filesScanned: 0, error: 'invalid_regex' };
|
|
127
|
+
}
|
|
128
|
+
let queryRe;
|
|
129
|
+
try {
|
|
130
|
+
queryRe = buildQueryRegExp(opts);
|
|
131
|
+
} catch {
|
|
132
|
+
return { results: [], truncated: false, filesScanned: 0, error: 'invalid_regex' };
|
|
133
|
+
}
|
|
134
|
+
const maxResults = opts.maxResults ?? DEFAULTS.maxResults;
|
|
135
|
+
const maxPerFile = opts.maxMatchesPerFile ?? DEFAULTS.maxMatchesPerFile;
|
|
136
|
+
const maxFileSize = opts.maxFileSize ?? DEFAULTS.maxFileSize;
|
|
137
|
+
const timeBudget = opts.nodeTimeBudgetMs ?? DEFAULTS.nodeTimeBudgetMs;
|
|
138
|
+
const started = Date.now();
|
|
139
|
+
|
|
140
|
+
let candidates = await gitListFiles(opts.root);
|
|
141
|
+
if (candidates == null) candidates = walkDir(opts.root);
|
|
142
|
+
candidates = candidates.map(normRel).filter((p) => !isHidden(p) && !hasIgnoredSegment(p));
|
|
143
|
+
|
|
144
|
+
const passesGlob = makeGlobFilter(opts.includeGlobs, opts.excludeGlobs);
|
|
145
|
+
const grouper = createGrouper(maxResults, maxPerFile);
|
|
146
|
+
const scanned = new Set();
|
|
147
|
+
let truncated = false;
|
|
148
|
+
let processed = 0;
|
|
149
|
+
let bytesSinceYield = 0;
|
|
150
|
+
|
|
151
|
+
for (const rel of candidates) {
|
|
152
|
+
if (Date.now() - started > timeBudget) { truncated = true; break; }
|
|
153
|
+
// Yield to the worker's own event loop periodically so a large scan stays responsive to a
|
|
154
|
+
// terminate() and a candidate list in the tens of thousands doesn't monopolize the loop.
|
|
155
|
+
if ((processed++ % FILE_YIELD_INTERVAL) === 0 || bytesSinceYield >= BYTE_YIELD_BUDGET) {
|
|
156
|
+
bytesSinceYield = 0;
|
|
157
|
+
await new Promise((r) => setImmediate(r));
|
|
158
|
+
}
|
|
159
|
+
if (!passesGlob(rel)) continue;
|
|
160
|
+
|
|
161
|
+
const full = join(opts.root, rel);
|
|
162
|
+
let lst;
|
|
163
|
+
try { lst = lstatSync(full); } catch { continue; }
|
|
164
|
+
if (lst.isSymbolicLink()) continue; // never follow symlinks
|
|
165
|
+
|
|
166
|
+
let real;
|
|
167
|
+
try { real = realpathSync(full); } catch { continue; }
|
|
168
|
+
const policy = isReadAllowed(real);
|
|
169
|
+
if (!policy.ok) continue;
|
|
170
|
+
|
|
171
|
+
let st;
|
|
172
|
+
try { st = statSync(real); } catch { continue; }
|
|
173
|
+
if (!st.isFile() || st.size > maxFileSize) continue;
|
|
174
|
+
|
|
175
|
+
let raw;
|
|
176
|
+
try { raw = readFileSync(real); } catch { continue; }
|
|
177
|
+
bytesSinceYield += raw.length;
|
|
178
|
+
if (isBinary(raw)) continue;
|
|
179
|
+
|
|
180
|
+
scanned.add(rel);
|
|
181
|
+
const lines = raw.toString('utf8').split(/\r?\n/);
|
|
182
|
+
let stop = false;
|
|
183
|
+
for (let i = 0; i < lines.length && !stop; i++) {
|
|
184
|
+
const text = lines[i];
|
|
185
|
+
if (!text || text.length > DEFAULTS.maxLineLength) continue; // skip empty / pathological long lines
|
|
186
|
+
queryRe.lastIndex = 0;
|
|
187
|
+
const submatches = [];
|
|
188
|
+
let m;
|
|
189
|
+
while ((m = queryRe.exec(text)) !== null) {
|
|
190
|
+
const start = m.index;
|
|
191
|
+
const end = m.index + m[0].length;
|
|
192
|
+
if (end > start) submatches.push({ start, end });
|
|
193
|
+
if (m.index === queryRe.lastIndex) queryRe.lastIndex++; // zero-width guard
|
|
194
|
+
if (submatches.length >= 1000) break; // pathological single-line match count
|
|
195
|
+
}
|
|
196
|
+
if (submatches.length) {
|
|
197
|
+
const ok = grouper.add(rel, { line: i + 1, text: text.slice(0, DEFAULTS.maxLineLength), submatches });
|
|
198
|
+
if (!ok) { truncated = true; stop = true; }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (stop) break;
|
|
202
|
+
}
|
|
203
|
+
return { results: grouper.results(), truncated: truncated || grouper.capped, filesScanned: scanned.size };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─── worker entry (skipped under node --test in the parent process) ───
|
|
207
|
+
if (!isMainThread && parentPort) {
|
|
208
|
+
runNodeScan(workerData || {})
|
|
209
|
+
.then((r) => {
|
|
210
|
+
if (r.error) parentPort.postMessage({ type: 'error', error: r.error });
|
|
211
|
+
else parentPort.postMessage({ type: 'done', results: r.results, truncated: r.truncated, filesScanned: r.filesScanned });
|
|
212
|
+
})
|
|
213
|
+
.catch(() => parentPort.postMessage({ type: 'error', error: 'search_failed' }));
|
|
214
|
+
}
|
|
@@ -60,17 +60,21 @@ let sessionId = null;
|
|
|
60
60
|
let transcriptPath = null;
|
|
61
61
|
let source = null;
|
|
62
62
|
let cwd = null;
|
|
63
|
+
let agentId = null;
|
|
63
64
|
try {
|
|
64
65
|
const parsed = JSON.parse(stdinData);
|
|
65
66
|
sessionId = parsed?.session_id || null;
|
|
66
67
|
transcriptPath = parsed?.transcript_path || null;
|
|
67
68
|
source = parsed?.source || null;
|
|
68
69
|
cwd = parsed?.cwd || null;
|
|
70
|
+
// Present only when the hook fires from within a subagent/teammate process;
|
|
71
|
+
// the server uses it to skip session-boundary resets for shared state.
|
|
72
|
+
agentId = parsed?.agent_id || null;
|
|
69
73
|
} catch { /* fine — the server tolerates missing fields */ }
|
|
70
74
|
debug(`payload source=${source} session_id=${sessionId} transcript=${transcriptPath}`);
|
|
71
75
|
|
|
72
76
|
const internalToken = process.env.CCVIEWER_INTERNAL_TOKEN || '';
|
|
73
|
-
const body = JSON.stringify({ source, sessionId, transcriptPath, cwd, ts: Date.now() });
|
|
77
|
+
const body = JSON.stringify({ source, sessionId, transcriptPath, cwd, agentId, ts: Date.now() });
|
|
74
78
|
const reqOpts = {
|
|
75
79
|
hostname: '127.0.0.1',
|
|
76
80
|
port: parseInt(port, 10),
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* task-bridge.js — hook bridge for Claude Code's task-checklist events.
|
|
4
|
+
*
|
|
5
|
+
* Feeds the GUI task-progress HUD: forwards every task mutation to cc-viewer's
|
|
6
|
+
* /api/task-event, where server/lib/task-state.js maintains the checklist and
|
|
7
|
+
* broadcasts it over SSE (`task_update`).
|
|
8
|
+
*
|
|
9
|
+
* Hook config in ~/.claude/settings.json (injected by ensure-hooks.js, tagged
|
|
10
|
+
* `# cc-viewer-managed`):
|
|
11
|
+
* "hooks": {
|
|
12
|
+
* "TaskCreated": [{ "hooks": [{ "type": "command", "command": "... task-bridge.js ..." }] }],
|
|
13
|
+
* "TaskCompleted":[{ "hooks": [{ "type": "command", "command": "... task-bridge.js ..." }] }],
|
|
14
|
+
* "PostToolUse": [{ "matcher": "TaskUpdate", "hooks": [{ "type": "command", ... }] }]
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* Payload shapes (CC 2.1.x):
|
|
18
|
+
* TaskCreated/TaskCompleted: { hook_event_name, session_id, transcript_path,
|
|
19
|
+
* cwd, task_id, task_subject, task_description?, teammate_name?, agent_id? }
|
|
20
|
+
* PostToolUse(TaskUpdate): { hook_event_name: "PostToolUse", session_id, ...,
|
|
21
|
+
* tool_name: "TaskUpdate", tool_input: { taskId, status?, owner?,
|
|
22
|
+
* subject?, description?, activeForm? } }
|
|
23
|
+
*
|
|
24
|
+
* Output contract (same as session-start-bridge.js): NOTHING on stdout —
|
|
25
|
+
* PostToolUse interprets stdout starting with "{" as decision JSON, and any
|
|
26
|
+
* stray bytes pollute the hook chain. Optional stderr only when
|
|
27
|
+
* CCVIEWER_DEBUG=1. ALWAYS exit 0: exit code 2 on TaskCreated/TaskCompleted
|
|
28
|
+
* rolls back task creation / prevents completion, so a failed notify must
|
|
29
|
+
* never surface as a non-zero exit.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readFileSync } from 'node:fs';
|
|
33
|
+
import http from 'node:http';
|
|
34
|
+
import https from 'node:https';
|
|
35
|
+
|
|
36
|
+
const debug = (msg) => {
|
|
37
|
+
if (process.env.CCVIEWER_DEBUG === '1') {
|
|
38
|
+
try { process.stderr.write(`[task-bridge] ${msg}\n`); } catch { /* ignore */ }
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const port = process.env.CCVIEWER_PORT;
|
|
43
|
+
const rawProtocol = process.env.CCVIEWER_PROTOCOL;
|
|
44
|
+
const isHttps = rawProtocol === 'https';
|
|
45
|
+
const httpClient = isHttps ? https : http;
|
|
46
|
+
|
|
47
|
+
// cc-viewer not running — exit silently (stdout must stay clean, see header).
|
|
48
|
+
if (!port) {
|
|
49
|
+
debug('CCVIEWER_PORT unset — exit silently');
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Drain stdin best-effort; capped to 64 KB to defang malformed huge payloads.
|
|
54
|
+
let stdinData = '';
|
|
55
|
+
try {
|
|
56
|
+
const buf = readFileSync(0);
|
|
57
|
+
stdinData = (buf.length > 64 * 1024 ? buf.slice(0, 64 * 1024) : buf).toString('utf-8');
|
|
58
|
+
} catch { /* stdin may not be piped — fine, still notify */ }
|
|
59
|
+
|
|
60
|
+
let parsed = null;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(stdinData);
|
|
63
|
+
} catch { /* fine — the server tolerates missing fields */ }
|
|
64
|
+
|
|
65
|
+
// Normalize to one canonical camelCase envelope regardless of hook event.
|
|
66
|
+
// TaskCreated/TaskCompleted carry snake_case task fields at the top level;
|
|
67
|
+
// PostToolUse carries a camelCase tool_input. Read both aliases defensively.
|
|
68
|
+
const hookEventName = parsed?.hook_event_name || null;
|
|
69
|
+
const toolInput = (parsed?.tool_input && typeof parsed.tool_input === 'object') ? parsed.tool_input : {};
|
|
70
|
+
const body = JSON.stringify({
|
|
71
|
+
hookEventName,
|
|
72
|
+
toolName: parsed?.tool_name || null,
|
|
73
|
+
sessionId: parsed?.session_id || null,
|
|
74
|
+
transcriptPath: parsed?.transcript_path || null,
|
|
75
|
+
cwd: parsed?.cwd || null,
|
|
76
|
+
teammateName: parsed?.teammate_name || null,
|
|
77
|
+
agentId: parsed?.agent_id || null,
|
|
78
|
+
taskId: parsed?.task_id ?? toolInput.taskId ?? null,
|
|
79
|
+
taskSubject: parsed?.task_subject ?? toolInput.subject ?? null,
|
|
80
|
+
taskDescription: parsed?.task_description ?? toolInput.description ?? null,
|
|
81
|
+
status: toolInput.status ?? null,
|
|
82
|
+
owner: toolInput.owner ?? null,
|
|
83
|
+
activeForm: toolInput.activeForm ?? null,
|
|
84
|
+
ts: Date.now(),
|
|
85
|
+
});
|
|
86
|
+
debug(`payload event=${hookEventName} tool=${parsed?.tool_name} taskId=${parsed?.task_id ?? toolInput.taskId}`);
|
|
87
|
+
|
|
88
|
+
const internalToken = process.env.CCVIEWER_INTERNAL_TOKEN || '';
|
|
89
|
+
const reqOpts = {
|
|
90
|
+
hostname: '127.0.0.1',
|
|
91
|
+
port: parseInt(port, 10),
|
|
92
|
+
path: '/api/task-event',
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
'Content-Length': Buffer.byteLength(body),
|
|
97
|
+
// Same anti-spoof header as the other bridges: matched against the
|
|
98
|
+
// server's per-startup INTERNAL_TOKEN, env-leaked only to the claude child.
|
|
99
|
+
...(internalToken ? { 'X-CCViewer-Internal': internalToken } : {}),
|
|
100
|
+
},
|
|
101
|
+
// Keep the timeout snappy so a stale cc-viewer never blocks the Claude Code
|
|
102
|
+
// hook chain for a noticeable beat.
|
|
103
|
+
timeout: 500,
|
|
104
|
+
};
|
|
105
|
+
if (isHttps) {
|
|
106
|
+
// Loopback HTTPS is typically self-signed; validation would reject.
|
|
107
|
+
reqOpts.rejectUnauthorized = false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let exited = false;
|
|
111
|
+
const finish = (reason) => {
|
|
112
|
+
if (exited) return;
|
|
113
|
+
exited = true;
|
|
114
|
+
if (reason) debug(reason);
|
|
115
|
+
process.exit(0);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
let req;
|
|
119
|
+
try {
|
|
120
|
+
req = httpClient.request(reqOpts, (res) => {
|
|
121
|
+
res.resume();
|
|
122
|
+
res.on('end', () => finish(`POST done (status=${res.statusCode})`));
|
|
123
|
+
});
|
|
124
|
+
req.on('error', (err) => finish(`POST error: ${err?.message}`));
|
|
125
|
+
req.on('timeout', () => { try { req.destroy(); } catch { /* ignore */ } finish('POST timeout'); });
|
|
126
|
+
// Wrap the synchronous write/end so an immediate EPIPE never bubbles into
|
|
127
|
+
// Claude Code's transcript (same defensive shape as the other bridges).
|
|
128
|
+
try {
|
|
129
|
+
req.write(body);
|
|
130
|
+
req.end();
|
|
131
|
+
} catch (err) {
|
|
132
|
+
finish(`req.write threw: ${err?.message}`);
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
finish(`request() threw: ${err?.message}`);
|
|
136
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-state.js — in-memory reducer for the Claude Code task checklist.
|
|
3
|
+
*
|
|
4
|
+
* Maintains the single shared checklist of the session this cc-viewer process
|
|
5
|
+
* hosts (fed by task-bridge.js via POST /api/task-event → deps.onTaskEvent).
|
|
6
|
+
* The checklist is deliberately ONE map, not per-session: the main agent and
|
|
7
|
+
* its teammates share the same task list in Claude Code, and each cc-viewer
|
|
8
|
+
* server process only receives hooks from its own claude child tree
|
|
9
|
+
* (CCVIEWER_PORT is injected per child). Cross-session leftovers are cleared
|
|
10
|
+
* by resetTasks() on session boundaries (startup/clear from the MAIN agent —
|
|
11
|
+
* see server.js onTaskSessionBoundary, which skips subagent events via
|
|
12
|
+
* agentId).
|
|
13
|
+
*
|
|
14
|
+
* L1-lib: pure module, Node builtins only, no imports.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** @type {string|null} last seen main session id (SSE tagging only) */
|
|
18
|
+
let _sessionId = null;
|
|
19
|
+
/** @type {Map<string, object>} insertion order = stable display order */
|
|
20
|
+
const _tasks = new Map();
|
|
21
|
+
|
|
22
|
+
const VALID_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
|
|
23
|
+
|
|
24
|
+
function _touch(task) {
|
|
25
|
+
task.updatedAt = Date.now();
|
|
26
|
+
return task;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Apply one normalized task event (the camelCase envelope task-bridge.js
|
|
31
|
+
* POSTs). Unknown/malformed payloads are ignored.
|
|
32
|
+
* @param {object} payload
|
|
33
|
+
*/
|
|
34
|
+
export function applyTaskEvent(payload) {
|
|
35
|
+
if (!payload || typeof payload !== 'object') return;
|
|
36
|
+
const { hookEventName } = payload;
|
|
37
|
+
const taskId = payload.taskId != null ? String(payload.taskId) : null;
|
|
38
|
+
// Validate BEFORE touching the session tag: a malformed/unknown event must
|
|
39
|
+
// not move the shouldResetTasks comparison base.
|
|
40
|
+
if (!taskId) return;
|
|
41
|
+
if (payload.sessionId && !payload.agentId) _sessionId = payload.sessionId;
|
|
42
|
+
|
|
43
|
+
if (hookEventName === 'TaskCreated') {
|
|
44
|
+
const existing = _tasks.get(taskId);
|
|
45
|
+
if (existing) {
|
|
46
|
+
// Idempotent re-fire: keep the current status, refresh the text fields
|
|
47
|
+
// and the teammate attribution (same field set as TaskCompleted).
|
|
48
|
+
if (payload.taskSubject != null) existing.subject = payload.taskSubject;
|
|
49
|
+
if (payload.taskDescription != null) existing.description = payload.taskDescription;
|
|
50
|
+
if (payload.teammateName != null) existing.teammateName = payload.teammateName;
|
|
51
|
+
_touch(existing);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
_tasks.set(taskId, _touch({
|
|
55
|
+
taskId,
|
|
56
|
+
subject: payload.taskSubject ?? null,
|
|
57
|
+
description: payload.taskDescription ?? null,
|
|
58
|
+
activeForm: null,
|
|
59
|
+
status: 'pending',
|
|
60
|
+
owner: null,
|
|
61
|
+
teammateName: payload.teammateName ?? null,
|
|
62
|
+
agentId: payload.agentId ?? null,
|
|
63
|
+
createdAt: Date.now(),
|
|
64
|
+
}));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (hookEventName === 'TaskCompleted') {
|
|
69
|
+
const existing = _tasks.get(taskId) || _stub(taskId, payload);
|
|
70
|
+
if (!_tasks.has(taskId)) _tasks.set(taskId, existing);
|
|
71
|
+
existing.status = 'completed';
|
|
72
|
+
if (payload.taskSubject != null) existing.subject = payload.taskSubject;
|
|
73
|
+
if (payload.taskDescription != null) existing.description = payload.taskDescription;
|
|
74
|
+
if (payload.teammateName != null) existing.teammateName = payload.teammateName;
|
|
75
|
+
_touch(existing);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (hookEventName === 'PostToolUse' && payload.toolName === 'TaskUpdate') {
|
|
80
|
+
const status = VALID_STATUSES.has(payload.status) ? payload.status : null;
|
|
81
|
+
if (status === 'deleted') {
|
|
82
|
+
_tasks.delete(taskId);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const existing = _tasks.get(taskId) || _stub(taskId, payload);
|
|
86
|
+
if (!_tasks.has(taskId)) _tasks.set(taskId, existing);
|
|
87
|
+
// Patch only the fields present in the event; a TaskUpdate may carry any
|
|
88
|
+
// subset (status flips, renames, owner claims). Re-opening a completed
|
|
89
|
+
// task (completed → in_progress → pending) is valid and passed through.
|
|
90
|
+
if (status) existing.status = status;
|
|
91
|
+
if (payload.taskSubject != null) existing.subject = payload.taskSubject;
|
|
92
|
+
if (payload.taskDescription != null) existing.description = payload.taskDescription;
|
|
93
|
+
if (payload.activeForm != null) existing.activeForm = payload.activeForm;
|
|
94
|
+
if (payload.owner != null) existing.owner = payload.owner;
|
|
95
|
+
if (payload.teammateName != null) existing.teammateName = payload.teammateName;
|
|
96
|
+
if (payload.agentId != null) existing.agentId = payload.agentId;
|
|
97
|
+
_touch(existing);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Unknown events (e.g. future Task* hooks) are ignored by design.
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Stub entry for updates that reference a task created before cc-viewer (or
|
|
104
|
+
// the hook chain) started watching — the frontend renders a placeholder.
|
|
105
|
+
function _stub(taskId, payload) {
|
|
106
|
+
return _touch({
|
|
107
|
+
taskId,
|
|
108
|
+
subject: payload.taskSubject ?? null,
|
|
109
|
+
description: payload.taskDescription ?? null,
|
|
110
|
+
activeForm: payload.activeForm ?? null,
|
|
111
|
+
status: 'pending',
|
|
112
|
+
owner: payload.owner ?? null,
|
|
113
|
+
teammateName: payload.teammateName ?? null,
|
|
114
|
+
agentId: payload.agentId ?? null,
|
|
115
|
+
createdAt: Date.now(),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Clear the whole checklist (session boundary: startup/clear by main agent). */
|
|
120
|
+
export function resetTasks() {
|
|
121
|
+
_tasks.clear();
|
|
122
|
+
_sessionId = null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Session-boundary gate (pure, unit-tested): the shared checklist resets on
|
|
127
|
+
* main-agent startup/clear, and on resume/fork to a DIFFERENT session (an
|
|
128
|
+
* in-terminal /resume switches conversations — old tasks must not mix into
|
|
129
|
+
* the resumed one; resuming the SAME session keeps the list). Subagent/
|
|
130
|
+
* teammate processes inherit CCVIEWER_PORT and fire their own SessionStart
|
|
131
|
+
* 'startup' events — those are identified by payload.agentId and must NOT
|
|
132
|
+
* wipe the shared list.
|
|
133
|
+
*/
|
|
134
|
+
export function shouldResetTasks(payload, currentSessionId) {
|
|
135
|
+
const { source, agentId, sessionId } = payload || {};
|
|
136
|
+
if (agentId) return false;
|
|
137
|
+
if (source === 'startup' || source === 'clear') return true;
|
|
138
|
+
if ((source === 'resume' || source === 'fork') && sessionId && sessionId !== currentSessionId) return true;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Full-snapshot view for SSE broadcast; insertion order preserved. */
|
|
143
|
+
export function getTaskSnapshot() {
|
|
144
|
+
return { sessionId: _sessionId, tasks: [..._tasks.values()] };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Test hook: restore pristine module state. */
|
|
148
|
+
export function __resetForTests() {
|
|
149
|
+
resetTasks();
|
|
150
|
+
}
|
package/server/pty-manager.js
CHANGED
|
@@ -12,7 +12,7 @@ import { mergeSettingsIntoArgs } from './lib/settings-merge.js';
|
|
|
12
12
|
import { MODEL_PROMPT_DIR } from './lib/model-system-prompts.js';
|
|
13
13
|
// Launch-time system-prompt/thinking-display pipeline lives in lib/launch-config.js
|
|
14
14
|
// (shared with the SDK link). Re-exported here for existing consumers/tests.
|
|
15
|
-
import { withDefaultThinkingDisplay, resolveLaunchSystemPrompt } from './lib/launch-config.js';
|
|
15
|
+
import { withDefaultThinkingDisplay, resolveLaunchSystemPrompt, insertBeforeDashDash } from './lib/launch-config.js';
|
|
16
16
|
export { withDefaultThinkingDisplay } from './lib/launch-config.js';
|
|
17
17
|
import { t, tFor } from './i18n.js';
|
|
18
18
|
|
|
@@ -437,7 +437,13 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
437
437
|
console.warn('[CC Viewer] system prompt build/render failed, launching without injected prompt:', err?.message || err);
|
|
438
438
|
sysPrompt = { args: [], loaded: [], model: null, entries: [] };
|
|
439
439
|
}
|
|
440
|
-
|
|
440
|
+
// Inject the system-prompt args before a literal `--` (tokens after it are prompt text
|
|
441
|
+
// and would swallow a flag), and relocate `--thinking-display` out of the prompt region
|
|
442
|
+
// too. Shared with the headless run link via launch-config.js so both spawn paths keep
|
|
443
|
+
// the same byte order.
|
|
444
|
+
const launchArgs = sysPrompt.args.length || finalExtraArgs.includes('--thinking-display')
|
|
445
|
+
? insertBeforeDashDash(finalExtraArgs, sysPrompt.args)
|
|
446
|
+
: finalExtraArgs;
|
|
441
447
|
|
|
442
448
|
let command = claudePath;
|
|
443
449
|
let args = ['--settings', settingsJson, ...launchArgs];
|
package/server/routes/events.js
CHANGED
|
@@ -90,6 +90,54 @@ function sessionStartNotify(req, res, parsedUrl, isLocal, deps) {
|
|
|
90
90
|
}
|
|
91
91
|
try { deps.onSessionStartNotify(payload); }
|
|
92
92
|
catch (err) { reportSwallowed('session-start-notify.dispatch', err); }
|
|
93
|
+
// Task-checklist boundary: reset on main-agent startup/clear, skipped for
|
|
94
|
+
// subagent events (payload.agentId). Separate deps callback so it is never
|
|
95
|
+
// swallowed by markSessionStart's source early-return.
|
|
96
|
+
try { deps.onTaskSessionBoundary?.(payload); }
|
|
97
|
+
catch (err) { reportSwallowed('task-session-boundary', err); }
|
|
98
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
99
|
+
res.end(JSON.stringify({ ok: true }));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Task-event hook notify (task-bridge.js): TaskCreated / TaskCompleted /
|
|
104
|
+
// PostToolUse(TaskUpdate) → the task-checklist reducer. Same security shape
|
|
105
|
+
// as sessionStartNotify (loopback-only + internal token + 16KB cap); the
|
|
106
|
+
// state update + debounced SSE broadcast live behind deps.onTaskEvent
|
|
107
|
+
// (server.js → lib/task-state.js).
|
|
108
|
+
function taskEventNotify(req, res, parsedUrl, isLocal, deps) {
|
|
109
|
+
if (!isLocal) {
|
|
110
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
111
|
+
res.end(JSON.stringify({ error: 'Loopback only' }));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (req.headers['x-ccviewer-internal'] !== deps.INTERNAL_TOKEN) {
|
|
115
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
116
|
+
res.end(JSON.stringify({ error: 'Invalid bridge token' }));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
let body = '';
|
|
120
|
+
let truncated = false;
|
|
121
|
+
req.on('data', chunk => {
|
|
122
|
+
body += chunk;
|
|
123
|
+
if (body.length > 16384) { truncated = true; req.destroy(); }
|
|
124
|
+
});
|
|
125
|
+
req.on('end', () => {
|
|
126
|
+
if (truncated) {
|
|
127
|
+
console.warn('[task-event] body exceeded 16KB cap — request destroyed');
|
|
128
|
+
return; // socket already closed by destroy()
|
|
129
|
+
}
|
|
130
|
+
let payload = {};
|
|
131
|
+
let badJson = false;
|
|
132
|
+
try { payload = body ? JSON.parse(body) : {}; }
|
|
133
|
+
catch { badJson = true; console.warn('[task-event] malformed JSON body'); }
|
|
134
|
+
if (badJson) {
|
|
135
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
136
|
+
res.end(JSON.stringify({ error: 'malformed JSON body' }));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
try { deps.onTaskEvent?.(payload); }
|
|
140
|
+
catch (err) { reportSwallowed('task-event.dispatch', err); }
|
|
93
141
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
94
142
|
res.end(JSON.stringify({ ok: true }));
|
|
95
143
|
});
|
|
@@ -362,6 +410,13 @@ async function events(req, res, parsedUrl, isLocal, deps) {
|
|
|
362
410
|
// 这样 watcher 的 sendToClients 不会在 load 阶段向该客户端推送 live entry。
|
|
363
411
|
deps.clients.push(res);
|
|
364
412
|
|
|
413
|
+
// 任务清单快照补推:内存态不在冷加载数据里,页面刷新/重连后横条要等下一次
|
|
414
|
+
// 任务事件才恢复 —— 连接建立即推一份当前快照(空列表也推,清掉客户端残留)。
|
|
415
|
+
try {
|
|
416
|
+
const snap = deps.getTaskSnapshot?.();
|
|
417
|
+
if (snap) sseWrite(res, `event: task_update\ndata: ${JSON.stringify({ sessionId: snap.sessionId, tasks: snap.tasks, ts: Date.now() })}\n\n`);
|
|
418
|
+
} catch (err) { reportSwallowed('sse.task_update.snapshot', err); }
|
|
419
|
+
|
|
365
420
|
// req.on('close') 在某些异常断连时不一定立即触发;res 端 close/error 兜底保证
|
|
366
421
|
// 不会在 clients 数组里留下幽灵 res,防止 sendToClients 后续写入触发慢泄漏。
|
|
367
422
|
const removeFromClients = () => {
|
|
@@ -402,6 +457,7 @@ async function requests(req, res) {
|
|
|
402
457
|
export const eventsRoutes = [
|
|
403
458
|
{ method: 'POST', match: 'exact', path: '/api/turn-end-notify', handler: turnEndNotify },
|
|
404
459
|
{ method: 'POST', match: 'exact', path: '/api/session-start-notify', handler: sessionStartNotify },
|
|
460
|
+
{ method: 'POST', match: 'exact', path: '/api/task-event', handler: taskEventNotify },
|
|
405
461
|
{ method: 'GET', match: 'exact', path: '/events', handler: events },
|
|
406
462
|
{ method: 'GET', match: 'exact', path: '/api/requests', handler: requests },
|
|
407
463
|
];
|