chatccc 0.2.198 → 0.2.199
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/agent-prompts/cursor_specific.md +13 -13
- package/bin/cccagent.mjs +17 -17
- package/config.sample.json +27 -27
- package/package.json +1 -1
- package/src/__tests__/agent-reload-config-rpc.test.ts +99 -99
- package/src/__tests__/builtin-chat-session.test.ts +277 -277
- package/src/__tests__/builtin-cli-json.test.ts +39 -39
- package/src/__tests__/builtin-config.test.ts +33 -33
- package/src/__tests__/builtin-context.test.ts +163 -163
- package/src/__tests__/builtin-file-tools.test.ts +224 -224
- package/src/__tests__/builtin-session-select.test.ts +116 -116
- package/src/__tests__/builtin-sigint.test.ts +56 -56
- package/src/__tests__/cards.test.ts +109 -109
- package/src/__tests__/ccc-adapter.test.ts +114 -114
- package/src/__tests__/chatgpt-subscription-rpc.test.ts +89 -89
- package/src/__tests__/chatgpt-subscription.test.ts +135 -135
- package/src/__tests__/chrome-devtools-guard.test.ts +165 -165
- package/src/__tests__/claude-raw-stream-log.test.ts +87 -87
- package/src/__tests__/codex-raw-stream-log.test.ts +163 -163
- package/src/__tests__/config-reload.test.ts +10 -10
- package/src/__tests__/config-sample.test.ts +18 -18
- package/src/__tests__/cursor-adapter.test.ts +66 -7
- package/src/__tests__/feishu-avatar.test.ts +40 -40
- package/src/__tests__/jsonl-stream.test.ts +79 -79
- package/src/__tests__/orchestrator.test.ts +200 -200
- package/src/__tests__/raw-stream-log.test.ts +106 -106
- package/src/__tests__/session.test.ts +40 -40
- package/src/__tests__/sim-platform.test.ts +12 -12
- package/src/__tests__/web-ui.test.ts +209 -209
- package/src/adapters/ccc-adapter.ts +121 -121
- package/src/adapters/claude-adapter.ts +603 -603
- package/src/adapters/codex-adapter.ts +380 -380
- package/src/adapters/cursor-adapter.ts +39 -6
- package/src/adapters/jsonl-stream.ts +157 -157
- package/src/adapters/raw-stream-log.ts +124 -124
- package/src/agent-reload-config-rpc.ts +34 -34
- package/src/builtin/cli.ts +473 -473
- package/src/builtin/context.ts +323 -323
- package/src/builtin/file-tools.ts +1072 -1072
- package/src/builtin/index.ts +404 -404
- package/src/builtin/session-select.ts +48 -48
- package/src/builtin/sigint.ts +50 -50
- package/src/cards.ts +195 -195
- package/src/chatgpt-subscription-rpc.ts +27 -27
- package/src/chatgpt-subscription.ts +299 -299
- package/src/chrome-devtools-guard.ts +318 -318
- package/src/config.ts +125 -125
- package/src/feishu-api.ts +49 -49
- package/src/orchestrator.ts +179 -179
- package/src/runtime-reload.ts +34 -34
- package/src/session.ts +141 -141
- package/src/web-ui.ts +205 -205
|
@@ -156,6 +156,26 @@ function isCursorAuthRelatedError(stderr: string): boolean {
|
|
|
156
156
|
);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
const CURSOR_VISIBLE_STDERR_MAX_CHARS = 1200;
|
|
160
|
+
|
|
161
|
+
function sanitizeCursorStderr(stderr: string): string {
|
|
162
|
+
return stderr
|
|
163
|
+
.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "")
|
|
164
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/gi, "Bearer <redacted>")
|
|
165
|
+
.replace(/\bsk-[A-Za-z0-9_-]{12,}\b/g, "<redacted-api-key>")
|
|
166
|
+
.replace(
|
|
167
|
+
/\b(api[_-]?key|token|authorization|password)\s*[:=]\s*["']?[^\s"']+/gi,
|
|
168
|
+
"$1=<redacted>",
|
|
169
|
+
)
|
|
170
|
+
.trim();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function formatCursorVisibleStderr(stderr: string): string {
|
|
174
|
+
const sanitized = sanitizeCursorStderr(stderr);
|
|
175
|
+
if (sanitized.length <= CURSOR_VISIBLE_STDERR_MAX_CHARS) return sanitized;
|
|
176
|
+
return `${sanitized.slice(0, CURSOR_VISIBLE_STDERR_MAX_CHARS)}\n...(stderr truncated)`;
|
|
177
|
+
}
|
|
178
|
+
|
|
159
179
|
export function formatCursorAgentEmptyOutputMessage(args: {
|
|
160
180
|
exitCode: number | null;
|
|
161
181
|
stdoutLength: number;
|
|
@@ -165,11 +185,12 @@ export function formatCursorAgentEmptyOutputMessage(args: {
|
|
|
165
185
|
if (args.stdoutLength !== 0) return null;
|
|
166
186
|
if (args.stderr.trim().length === 0) return null;
|
|
167
187
|
|
|
188
|
+
const stderrBlock = `[Cursor stderr] exit=${args.exitCode ?? "unknown"}:\n${formatCursorVisibleStderr(args.stderr)}`;
|
|
168
189
|
if (isCursorAuthRelatedError(args.stderr)) {
|
|
169
|
-
return
|
|
190
|
+
return `Cursor Agent 没有返回内容。检测到认证相关错误,可能需要重新登录 Cursor Agent,或配置 CURSOR_API_KEY。请在本机运行 agent status 检查状态;如未登录,请运行 agent login 后重试。\n\n${stderrBlock}`;
|
|
170
191
|
}
|
|
171
192
|
|
|
172
|
-
return
|
|
193
|
+
return `Cursor Agent 没有返回内容。底层命令异常退出,错误信息如下:\n\n${stderrBlock}`;
|
|
173
194
|
}
|
|
174
195
|
|
|
175
196
|
function createCursorAgentFailureError(info: CursorProcessCloseInfo): Error {
|
|
@@ -437,6 +458,7 @@ async function* readJsonLines(
|
|
|
437
458
|
debugTag?: string,
|
|
438
459
|
rawLog?: RawStreamLogHandle | null,
|
|
439
460
|
stats?: CursorStreamStats,
|
|
461
|
+
idleTimeoutMs?: number,
|
|
440
462
|
): AsyncGenerator<CursorMessageLine> {
|
|
441
463
|
const tag = debugTag ?? "cursor";
|
|
442
464
|
yield* readJsonLinesWithBadJsonIdleWatchdog<CursorMessageLine>({
|
|
@@ -445,6 +467,7 @@ async function* readJsonLines(
|
|
|
445
467
|
tag,
|
|
446
468
|
signal,
|
|
447
469
|
rawLog,
|
|
470
|
+
idleTimeoutMs,
|
|
448
471
|
parse: (line) => JSON.parse(line) as CursorMessageLine,
|
|
449
472
|
onRawLine: (line) => {
|
|
450
473
|
if (stats) {
|
|
@@ -472,11 +495,18 @@ class CursorAdapter implements ToolAdapter {
|
|
|
472
495
|
private metaStore: CursorSessionMetaStore;
|
|
473
496
|
private modelOverride: string | undefined;
|
|
474
497
|
private spawnImpl: CursorSpawn;
|
|
475
|
-
|
|
476
|
-
|
|
498
|
+
private badJsonIdleTimeoutMs: number | undefined;
|
|
499
|
+
|
|
500
|
+
constructor(
|
|
501
|
+
metaStore: CursorSessionMetaStore,
|
|
502
|
+
modelOverride?: string,
|
|
503
|
+
spawnImpl: CursorSpawn = spawn,
|
|
504
|
+
badJsonIdleTimeoutMs?: number,
|
|
505
|
+
) {
|
|
477
506
|
this.metaStore = metaStore;
|
|
478
507
|
this.modelOverride = modelOverride;
|
|
479
508
|
this.spawnImpl = spawnImpl;
|
|
509
|
+
this.badJsonIdleTimeoutMs = badJsonIdleTimeoutMs;
|
|
480
510
|
}
|
|
481
511
|
|
|
482
512
|
async createSession(cwd: string): Promise<CreateSessionResult> {
|
|
@@ -485,7 +515,7 @@ class CursorAdapter implements ToolAdapter {
|
|
|
485
515
|
const stats = createCursorStreamStats();
|
|
486
516
|
this.activeProcs.add(proc);
|
|
487
517
|
|
|
488
|
-
for await (const msg of readJsonLines(proc, undefined, "createSession", null, stats)) {
|
|
518
|
+
for await (const msg of readJsonLines(proc, undefined, "createSession", null, stats, this.badJsonIdleTimeoutMs)) {
|
|
489
519
|
if (msg.type === "system" && msg.subtype === "init" && msg.session_id) {
|
|
490
520
|
const sessionId = msg.session_id;
|
|
491
521
|
await this.metaStore
|
|
@@ -554,7 +584,7 @@ class CursorAdapter implements ToolAdapter {
|
|
|
554
584
|
const stats = createCursorStreamStats();
|
|
555
585
|
|
|
556
586
|
try {
|
|
557
|
-
for await (const raw of readJsonLines(proc, signal, sessionId, rawLog, stats)) {
|
|
587
|
+
for await (const raw of readJsonLines(proc, signal, sessionId, rawLog, stats, this.badJsonIdleTimeoutMs)) {
|
|
558
588
|
if (signal?.aborted) break;
|
|
559
589
|
if (
|
|
560
590
|
raw.type === "system" &&
|
|
@@ -627,6 +657,8 @@ export interface CreateCursorAdapterOptions {
|
|
|
627
657
|
model?: string;
|
|
628
658
|
/** 注入自定义 spawn 实现(测试用)。 */
|
|
629
659
|
spawn?: CursorSpawn;
|
|
660
|
+
/** Test-only override for the bad JSON idle watchdog threshold. */
|
|
661
|
+
badJsonIdleTimeoutMs?: number;
|
|
630
662
|
}
|
|
631
663
|
|
|
632
664
|
export function createCursorAdapter(
|
|
@@ -636,5 +668,6 @@ export function createCursorAdapter(
|
|
|
636
668
|
options.metaStore ?? defaultCursorSessionMetaStore,
|
|
637
669
|
options.model,
|
|
638
670
|
options.spawn,
|
|
671
|
+
options.badJsonIdleTimeoutMs,
|
|
639
672
|
);
|
|
640
673
|
}
|
|
@@ -1,157 +1,157 @@
|
|
|
1
|
-
import { createInterface } from "node:readline";
|
|
2
|
-
|
|
3
|
-
interface JsonLineSink {
|
|
4
|
-
writeLine(line: string): void;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export const DEFAULT_BAD_JSON_IDLE_TIMEOUT_MS = 120_000;
|
|
8
|
-
export const BAD_JSON_IDLE_TIMEOUT_CODE = "BAD_JSON_IDLE_TIMEOUT";
|
|
9
|
-
|
|
10
|
-
export class BadJsonIdleTimeoutError extends Error {
|
|
11
|
-
readonly code = BAD_JSON_IDLE_TIMEOUT_CODE;
|
|
12
|
-
readonly tool: string;
|
|
13
|
-
readonly tag: string;
|
|
14
|
-
readonly timeoutMs: number;
|
|
15
|
-
readonly lineNumber: number;
|
|
16
|
-
readonly lineExcerpt: string;
|
|
17
|
-
readonly parseError: string;
|
|
18
|
-
|
|
19
|
-
constructor(args: {
|
|
20
|
-
tool: string;
|
|
21
|
-
tag: string;
|
|
22
|
-
timeoutMs: number;
|
|
23
|
-
lineNumber: number;
|
|
24
|
-
lineExcerpt: string;
|
|
25
|
-
parseError: string;
|
|
26
|
-
}) {
|
|
27
|
-
super(
|
|
28
|
-
`${args.tool} stream stopped after invalid JSON for ${args.timeoutMs}ms ` +
|
|
29
|
-
`(tag=${args.tag}, line=${args.lineNumber}): ${args.lineExcerpt}`,
|
|
30
|
-
);
|
|
31
|
-
this.name = "BadJsonIdleTimeoutError";
|
|
32
|
-
this.tool = args.tool;
|
|
33
|
-
this.tag = args.tag;
|
|
34
|
-
this.timeoutMs = args.timeoutMs;
|
|
35
|
-
this.lineNumber = args.lineNumber;
|
|
36
|
-
this.lineExcerpt = args.lineExcerpt;
|
|
37
|
-
this.parseError = args.parseError;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface ReadJsonLinesDoneInfo {
|
|
42
|
-
lineCount: number;
|
|
43
|
-
signalAborted: boolean;
|
|
44
|
-
protocolError: BadJsonIdleTimeoutError | null;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface ReadJsonLinesOptions<T> {
|
|
48
|
-
input: NodeJS.ReadableStream;
|
|
49
|
-
tool: string;
|
|
50
|
-
tag?: string;
|
|
51
|
-
signal?: AbortSignal;
|
|
52
|
-
rawLog?: JsonLineSink | null;
|
|
53
|
-
idleTimeoutMs?: number;
|
|
54
|
-
parse?: (line: string) => T;
|
|
55
|
-
onRawLine?: (line: string, lineNumber: number) => void;
|
|
56
|
-
onParsedLine?: (value: T, line: string, lineNumber: number) => void;
|
|
57
|
-
onDone?: (info: ReadJsonLinesDoneInfo) => void;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function isJsonLike(line: string): boolean {
|
|
61
|
-
return line.startsWith("{") || line.startsWith("[");
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function lineExcerpt(line: string): string {
|
|
65
|
-
const max = 500;
|
|
66
|
-
return line.length <= max ? line : `${line.slice(0, max)}...`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function parseErrorMessage(error: unknown): string {
|
|
70
|
-
return error instanceof Error ? error.message : String(error);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export async function* readJsonLinesWithBadJsonIdleWatchdog<T>({
|
|
74
|
-
input,
|
|
75
|
-
tool,
|
|
76
|
-
tag = tool,
|
|
77
|
-
signal,
|
|
78
|
-
rawLog,
|
|
79
|
-
idleTimeoutMs = DEFAULT_BAD_JSON_IDLE_TIMEOUT_MS,
|
|
80
|
-
parse = (line: string) => JSON.parse(line) as T,
|
|
81
|
-
onRawLine,
|
|
82
|
-
onParsedLine,
|
|
83
|
-
onDone,
|
|
84
|
-
}: ReadJsonLinesOptions<T>): AsyncGenerator<T> {
|
|
85
|
-
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
86
|
-
let lineCount = 0;
|
|
87
|
-
let protocolError: BadJsonIdleTimeoutError | null = null;
|
|
88
|
-
let badJsonIdleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
89
|
-
|
|
90
|
-
const clearBadJsonIdleTimer = () => {
|
|
91
|
-
if (!badJsonIdleTimer) return;
|
|
92
|
-
clearTimeout(badJsonIdleTimer);
|
|
93
|
-
badJsonIdleTimer = null;
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const armBadJsonIdleTimer = (line: string, lineNumber: number, error: unknown) => {
|
|
97
|
-
if (idleTimeoutMs <= 0) return;
|
|
98
|
-
clearBadJsonIdleTimer();
|
|
99
|
-
badJsonIdleTimer = setTimeout(() => {
|
|
100
|
-
protocolError = new BadJsonIdleTimeoutError({
|
|
101
|
-
tool,
|
|
102
|
-
tag,
|
|
103
|
-
timeoutMs: idleTimeoutMs,
|
|
104
|
-
lineNumber,
|
|
105
|
-
lineExcerpt: lineExcerpt(line),
|
|
106
|
-
parseError: parseErrorMessage(error),
|
|
107
|
-
});
|
|
108
|
-
rl.close();
|
|
109
|
-
}, idleTimeoutMs);
|
|
110
|
-
const timerWithUnref = badJsonIdleTimer as ReturnType<typeof setTimeout> & {
|
|
111
|
-
unref?: () => void;
|
|
112
|
-
};
|
|
113
|
-
timerWithUnref.unref?.();
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
const onAbort = () => {
|
|
117
|
-
clearBadJsonIdleTimer();
|
|
118
|
-
rl.close();
|
|
119
|
-
};
|
|
120
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
121
|
-
|
|
122
|
-
try {
|
|
123
|
-
for await (const line of rl) {
|
|
124
|
-
if (signal?.aborted) break;
|
|
125
|
-
lineCount++;
|
|
126
|
-
onRawLine?.(line, lineCount);
|
|
127
|
-
const trimmed = line.trim();
|
|
128
|
-
if (!trimmed) continue;
|
|
129
|
-
|
|
130
|
-
clearBadJsonIdleTimer();
|
|
131
|
-
rawLog?.writeLine(trimmed);
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
const parsed = parse(trimmed);
|
|
135
|
-
onParsedLine?.(parsed, trimmed, lineCount);
|
|
136
|
-
yield parsed;
|
|
137
|
-
} catch (error) {
|
|
138
|
-
if (isJsonLike(trimmed)) {
|
|
139
|
-
armBadJsonIdleTimer(trimmed, lineCount, error);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (protocolError && !signal?.aborted) {
|
|
145
|
-
throw protocolError;
|
|
146
|
-
}
|
|
147
|
-
} finally {
|
|
148
|
-
signal?.removeEventListener("abort", onAbort);
|
|
149
|
-
clearBadJsonIdleTimer();
|
|
150
|
-
rl.close();
|
|
151
|
-
onDone?.({
|
|
152
|
-
lineCount,
|
|
153
|
-
signalAborted: signal?.aborted ?? false,
|
|
154
|
-
protocolError,
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
}
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
|
|
3
|
+
interface JsonLineSink {
|
|
4
|
+
writeLine(line: string): void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_BAD_JSON_IDLE_TIMEOUT_MS = 120_000;
|
|
8
|
+
export const BAD_JSON_IDLE_TIMEOUT_CODE = "BAD_JSON_IDLE_TIMEOUT";
|
|
9
|
+
|
|
10
|
+
export class BadJsonIdleTimeoutError extends Error {
|
|
11
|
+
readonly code = BAD_JSON_IDLE_TIMEOUT_CODE;
|
|
12
|
+
readonly tool: string;
|
|
13
|
+
readonly tag: string;
|
|
14
|
+
readonly timeoutMs: number;
|
|
15
|
+
readonly lineNumber: number;
|
|
16
|
+
readonly lineExcerpt: string;
|
|
17
|
+
readonly parseError: string;
|
|
18
|
+
|
|
19
|
+
constructor(args: {
|
|
20
|
+
tool: string;
|
|
21
|
+
tag: string;
|
|
22
|
+
timeoutMs: number;
|
|
23
|
+
lineNumber: number;
|
|
24
|
+
lineExcerpt: string;
|
|
25
|
+
parseError: string;
|
|
26
|
+
}) {
|
|
27
|
+
super(
|
|
28
|
+
`${args.tool} stream stopped after invalid JSON for ${args.timeoutMs}ms ` +
|
|
29
|
+
`(tag=${args.tag}, line=${args.lineNumber}): ${args.lineExcerpt}`,
|
|
30
|
+
);
|
|
31
|
+
this.name = "BadJsonIdleTimeoutError";
|
|
32
|
+
this.tool = args.tool;
|
|
33
|
+
this.tag = args.tag;
|
|
34
|
+
this.timeoutMs = args.timeoutMs;
|
|
35
|
+
this.lineNumber = args.lineNumber;
|
|
36
|
+
this.lineExcerpt = args.lineExcerpt;
|
|
37
|
+
this.parseError = args.parseError;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ReadJsonLinesDoneInfo {
|
|
42
|
+
lineCount: number;
|
|
43
|
+
signalAborted: boolean;
|
|
44
|
+
protocolError: BadJsonIdleTimeoutError | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ReadJsonLinesOptions<T> {
|
|
48
|
+
input: NodeJS.ReadableStream;
|
|
49
|
+
tool: string;
|
|
50
|
+
tag?: string;
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
rawLog?: JsonLineSink | null;
|
|
53
|
+
idleTimeoutMs?: number;
|
|
54
|
+
parse?: (line: string) => T;
|
|
55
|
+
onRawLine?: (line: string, lineNumber: number) => void;
|
|
56
|
+
onParsedLine?: (value: T, line: string, lineNumber: number) => void;
|
|
57
|
+
onDone?: (info: ReadJsonLinesDoneInfo) => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isJsonLike(line: string): boolean {
|
|
61
|
+
return line.startsWith("{") || line.startsWith("[");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function lineExcerpt(line: string): string {
|
|
65
|
+
const max = 500;
|
|
66
|
+
return line.length <= max ? line : `${line.slice(0, max)}...`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseErrorMessage(error: unknown): string {
|
|
70
|
+
return error instanceof Error ? error.message : String(error);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function* readJsonLinesWithBadJsonIdleWatchdog<T>({
|
|
74
|
+
input,
|
|
75
|
+
tool,
|
|
76
|
+
tag = tool,
|
|
77
|
+
signal,
|
|
78
|
+
rawLog,
|
|
79
|
+
idleTimeoutMs = DEFAULT_BAD_JSON_IDLE_TIMEOUT_MS,
|
|
80
|
+
parse = (line: string) => JSON.parse(line) as T,
|
|
81
|
+
onRawLine,
|
|
82
|
+
onParsedLine,
|
|
83
|
+
onDone,
|
|
84
|
+
}: ReadJsonLinesOptions<T>): AsyncGenerator<T> {
|
|
85
|
+
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
86
|
+
let lineCount = 0;
|
|
87
|
+
let protocolError: BadJsonIdleTimeoutError | null = null;
|
|
88
|
+
let badJsonIdleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
89
|
+
|
|
90
|
+
const clearBadJsonIdleTimer = () => {
|
|
91
|
+
if (!badJsonIdleTimer) return;
|
|
92
|
+
clearTimeout(badJsonIdleTimer);
|
|
93
|
+
badJsonIdleTimer = null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const armBadJsonIdleTimer = (line: string, lineNumber: number, error: unknown) => {
|
|
97
|
+
if (idleTimeoutMs <= 0) return;
|
|
98
|
+
clearBadJsonIdleTimer();
|
|
99
|
+
badJsonIdleTimer = setTimeout(() => {
|
|
100
|
+
protocolError = new BadJsonIdleTimeoutError({
|
|
101
|
+
tool,
|
|
102
|
+
tag,
|
|
103
|
+
timeoutMs: idleTimeoutMs,
|
|
104
|
+
lineNumber,
|
|
105
|
+
lineExcerpt: lineExcerpt(line),
|
|
106
|
+
parseError: parseErrorMessage(error),
|
|
107
|
+
});
|
|
108
|
+
rl.close();
|
|
109
|
+
}, idleTimeoutMs);
|
|
110
|
+
const timerWithUnref = badJsonIdleTimer as ReturnType<typeof setTimeout> & {
|
|
111
|
+
unref?: () => void;
|
|
112
|
+
};
|
|
113
|
+
timerWithUnref.unref?.();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const onAbort = () => {
|
|
117
|
+
clearBadJsonIdleTimer();
|
|
118
|
+
rl.close();
|
|
119
|
+
};
|
|
120
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
for await (const line of rl) {
|
|
124
|
+
if (signal?.aborted) break;
|
|
125
|
+
lineCount++;
|
|
126
|
+
onRawLine?.(line, lineCount);
|
|
127
|
+
const trimmed = line.trim();
|
|
128
|
+
if (!trimmed) continue;
|
|
129
|
+
|
|
130
|
+
clearBadJsonIdleTimer();
|
|
131
|
+
rawLog?.writeLine(trimmed);
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const parsed = parse(trimmed);
|
|
135
|
+
onParsedLine?.(parsed, trimmed, lineCount);
|
|
136
|
+
yield parsed;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (isJsonLike(trimmed)) {
|
|
139
|
+
armBadJsonIdleTimer(trimmed, lineCount, error);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (protocolError && !signal?.aborted) {
|
|
145
|
+
throw protocolError;
|
|
146
|
+
}
|
|
147
|
+
} finally {
|
|
148
|
+
signal?.removeEventListener("abort", onAbort);
|
|
149
|
+
clearBadJsonIdleTimer();
|
|
150
|
+
rl.close();
|
|
151
|
+
onDone?.({
|
|
152
|
+
lineCount,
|
|
153
|
+
signalAborted: signal?.aborted ?? false,
|
|
154
|
+
protocolError,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -1,124 +1,124 @@
|
|
|
1
|
-
import { createWriteStream, type Dirent } from "node:fs";
|
|
2
|
-
import { mkdir, readdir, rm, stat, unlink } from "node:fs/promises";
|
|
3
|
-
import { once } from "node:events";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { createGzip } from "node:zlib";
|
|
6
|
-
|
|
7
|
-
export interface RawStreamLogOptions {
|
|
8
|
-
enabled: boolean;
|
|
9
|
-
rootDir: string;
|
|
10
|
-
tool: string;
|
|
11
|
-
sessionId: string;
|
|
12
|
-
label: string;
|
|
13
|
-
maxBytesPerTurn: number;
|
|
14
|
-
retentionDays: number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface RawStreamLogHandle {
|
|
18
|
-
filePath: string;
|
|
19
|
-
writeLine(line: string): void;
|
|
20
|
-
close(options: { keep: boolean }): Promise<void>;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function sanitizeLogPathSegment(value: string): string {
|
|
24
|
-
const safe = value.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^[._]+|_+$/g, "");
|
|
25
|
-
return safe || "unknown";
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async function cleanupOldRawStreamLogs(rootDir: string, retentionDays: number): Promise<void> {
|
|
29
|
-
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return;
|
|
30
|
-
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
31
|
-
|
|
32
|
-
const visit = async (dir: string): Promise<void> => {
|
|
33
|
-
let entries: Dirent[];
|
|
34
|
-
try {
|
|
35
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
36
|
-
} catch {
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
await Promise.all(entries.map(async (entry) => {
|
|
41
|
-
const path = join(dir, entry.name);
|
|
42
|
-
if (entry.isDirectory()) {
|
|
43
|
-
await visit(path);
|
|
44
|
-
try {
|
|
45
|
-
await rm(path, { recursive: false });
|
|
46
|
-
} catch {
|
|
47
|
-
// Directory is not empty or already gone.
|
|
48
|
-
}
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (!entry.isFile()) return;
|
|
53
|
-
try {
|
|
54
|
-
const info = await stat(path);
|
|
55
|
-
if (info.mtimeMs < cutoff) await rm(path, { force: true });
|
|
56
|
-
} catch {
|
|
57
|
-
// Best-effort cleanup only.
|
|
58
|
-
}
|
|
59
|
-
}));
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
await visit(rootDir);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export async function createRawStreamLog(options: RawStreamLogOptions): Promise<RawStreamLogHandle | null> {
|
|
66
|
-
if (!options.enabled) return null;
|
|
67
|
-
|
|
68
|
-
const maxBytes = Math.max(0, Math.floor(options.maxBytesPerTurn));
|
|
69
|
-
const tool = sanitizeLogPathSegment(options.tool);
|
|
70
|
-
const session = sanitizeLogPathSegment(options.sessionId);
|
|
71
|
-
const label = sanitizeLogPathSegment(options.label);
|
|
72
|
-
const dir = join(options.rootDir, tool, session);
|
|
73
|
-
await mkdir(dir, { recursive: true });
|
|
74
|
-
void cleanupOldRawStreamLogs(join(options.rootDir, tool), options.retentionDays);
|
|
75
|
-
|
|
76
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
77
|
-
const filePath = join(dir, `${timestamp}-${label}.jsonl.gz`);
|
|
78
|
-
const output = createWriteStream(filePath);
|
|
79
|
-
const gzip = createGzip();
|
|
80
|
-
gzip.pipe(output);
|
|
81
|
-
|
|
82
|
-
let bytes = 0;
|
|
83
|
-
let truncated = false;
|
|
84
|
-
let ended = false;
|
|
85
|
-
|
|
86
|
-
const writeLine = (line: string): void => {
|
|
87
|
-
if (ended || truncated) return;
|
|
88
|
-
const payload = `${line}\n`;
|
|
89
|
-
const payloadBytes = Buffer.byteLength(payload, "utf-8");
|
|
90
|
-
if (maxBytes > 0 && bytes + payloadBytes > maxBytes) {
|
|
91
|
-
const marker = JSON.stringify({
|
|
92
|
-
type: "chatccc_raw_stream_log_truncated",
|
|
93
|
-
reason: "max_bytes_per_turn_exceeded",
|
|
94
|
-
maxBytesPerTurn: maxBytes,
|
|
95
|
-
writtenBytes: bytes,
|
|
96
|
-
});
|
|
97
|
-
gzip.write(`${marker}\n`);
|
|
98
|
-
truncated = true;
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
bytes += payloadBytes;
|
|
102
|
-
gzip.write(payload);
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const close = async ({ keep }: { keep: boolean }): Promise<void> => {
|
|
106
|
-
if (ended) return;
|
|
107
|
-
ended = true;
|
|
108
|
-
gzip.end();
|
|
109
|
-
try {
|
|
110
|
-
await once(output, "finish");
|
|
111
|
-
} catch {
|
|
112
|
-
// Ignore close errors; caller cannot recover from debug log failures.
|
|
113
|
-
}
|
|
114
|
-
if (!keep) {
|
|
115
|
-
try {
|
|
116
|
-
await unlink(filePath);
|
|
117
|
-
} catch {
|
|
118
|
-
// Best-effort cleanup only.
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
return { filePath, writeLine, close };
|
|
124
|
-
}
|
|
1
|
+
import { createWriteStream, type Dirent } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, rm, stat, unlink } from "node:fs/promises";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createGzip } from "node:zlib";
|
|
6
|
+
|
|
7
|
+
export interface RawStreamLogOptions {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
rootDir: string;
|
|
10
|
+
tool: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
label: string;
|
|
13
|
+
maxBytesPerTurn: number;
|
|
14
|
+
retentionDays: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RawStreamLogHandle {
|
|
18
|
+
filePath: string;
|
|
19
|
+
writeLine(line: string): void;
|
|
20
|
+
close(options: { keep: boolean }): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function sanitizeLogPathSegment(value: string): string {
|
|
24
|
+
const safe = value.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^[._]+|_+$/g, "");
|
|
25
|
+
return safe || "unknown";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function cleanupOldRawStreamLogs(rootDir: string, retentionDays: number): Promise<void> {
|
|
29
|
+
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return;
|
|
30
|
+
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
const visit = async (dir: string): Promise<void> => {
|
|
33
|
+
let entries: Dirent[];
|
|
34
|
+
try {
|
|
35
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
36
|
+
} catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await Promise.all(entries.map(async (entry) => {
|
|
41
|
+
const path = join(dir, entry.name);
|
|
42
|
+
if (entry.isDirectory()) {
|
|
43
|
+
await visit(path);
|
|
44
|
+
try {
|
|
45
|
+
await rm(path, { recursive: false });
|
|
46
|
+
} catch {
|
|
47
|
+
// Directory is not empty or already gone.
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!entry.isFile()) return;
|
|
53
|
+
try {
|
|
54
|
+
const info = await stat(path);
|
|
55
|
+
if (info.mtimeMs < cutoff) await rm(path, { force: true });
|
|
56
|
+
} catch {
|
|
57
|
+
// Best-effort cleanup only.
|
|
58
|
+
}
|
|
59
|
+
}));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
await visit(rootDir);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function createRawStreamLog(options: RawStreamLogOptions): Promise<RawStreamLogHandle | null> {
|
|
66
|
+
if (!options.enabled) return null;
|
|
67
|
+
|
|
68
|
+
const maxBytes = Math.max(0, Math.floor(options.maxBytesPerTurn));
|
|
69
|
+
const tool = sanitizeLogPathSegment(options.tool);
|
|
70
|
+
const session = sanitizeLogPathSegment(options.sessionId);
|
|
71
|
+
const label = sanitizeLogPathSegment(options.label);
|
|
72
|
+
const dir = join(options.rootDir, tool, session);
|
|
73
|
+
await mkdir(dir, { recursive: true });
|
|
74
|
+
void cleanupOldRawStreamLogs(join(options.rootDir, tool), options.retentionDays);
|
|
75
|
+
|
|
76
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
77
|
+
const filePath = join(dir, `${timestamp}-${label}.jsonl.gz`);
|
|
78
|
+
const output = createWriteStream(filePath);
|
|
79
|
+
const gzip = createGzip();
|
|
80
|
+
gzip.pipe(output);
|
|
81
|
+
|
|
82
|
+
let bytes = 0;
|
|
83
|
+
let truncated = false;
|
|
84
|
+
let ended = false;
|
|
85
|
+
|
|
86
|
+
const writeLine = (line: string): void => {
|
|
87
|
+
if (ended || truncated) return;
|
|
88
|
+
const payload = `${line}\n`;
|
|
89
|
+
const payloadBytes = Buffer.byteLength(payload, "utf-8");
|
|
90
|
+
if (maxBytes > 0 && bytes + payloadBytes > maxBytes) {
|
|
91
|
+
const marker = JSON.stringify({
|
|
92
|
+
type: "chatccc_raw_stream_log_truncated",
|
|
93
|
+
reason: "max_bytes_per_turn_exceeded",
|
|
94
|
+
maxBytesPerTurn: maxBytes,
|
|
95
|
+
writtenBytes: bytes,
|
|
96
|
+
});
|
|
97
|
+
gzip.write(`${marker}\n`);
|
|
98
|
+
truncated = true;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
bytes += payloadBytes;
|
|
102
|
+
gzip.write(payload);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const close = async ({ keep }: { keep: boolean }): Promise<void> => {
|
|
106
|
+
if (ended) return;
|
|
107
|
+
ended = true;
|
|
108
|
+
gzip.end();
|
|
109
|
+
try {
|
|
110
|
+
await once(output, "finish");
|
|
111
|
+
} catch {
|
|
112
|
+
// Ignore close errors; caller cannot recover from debug log failures.
|
|
113
|
+
}
|
|
114
|
+
if (!keep) {
|
|
115
|
+
try {
|
|
116
|
+
await unlink(filePath);
|
|
117
|
+
} catch {
|
|
118
|
+
// Best-effort cleanup only.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return { filePath, writeLine, close };
|
|
124
|
+
}
|