chatccc 0.2.197 → 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 +2 -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 -120
- 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 +114 -1
- package/src/__tests__/feishu-avatar.test.ts +40 -40
- package/src/__tests__/jsonl-stream.test.ts +79 -0
- 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 -392
- package/src/adapters/cursor-adapter.ts +56 -30
- package/src/adapters/jsonl-stream.ts +157 -0
- 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
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
9
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
|
-
import { createInterface } from "node:readline";
|
|
12
11
|
import { fileURLToPath } from "node:url";
|
|
13
12
|
|
|
14
13
|
import type {
|
|
@@ -30,6 +29,7 @@ import {
|
|
|
30
29
|
createRawStreamLog,
|
|
31
30
|
type RawStreamLogHandle,
|
|
32
31
|
} from "./raw-stream-log.ts";
|
|
32
|
+
import { readJsonLinesWithBadJsonIdleWatchdog } from "./jsonl-stream.ts";
|
|
33
33
|
|
|
34
34
|
// ---------------------------------------------------------------------------
|
|
35
35
|
// 特殊注入提示
|
|
@@ -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,35 +458,30 @@ 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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
464
|
+
yield* readJsonLinesWithBadJsonIdleWatchdog<CursorMessageLine>({
|
|
465
|
+
input: proc.stdout!,
|
|
466
|
+
tool: "cursor",
|
|
467
|
+
tag,
|
|
468
|
+
signal,
|
|
469
|
+
rawLog,
|
|
470
|
+
idleTimeoutMs,
|
|
471
|
+
parse: (line) => JSON.parse(line) as CursorMessageLine,
|
|
472
|
+
onRawLine: (line) => {
|
|
451
473
|
if (stats) {
|
|
452
474
|
stats.rawLineCount++;
|
|
453
475
|
stats.stdoutLength += Buffer.byteLength(line, "utf-8") + 1;
|
|
454
476
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
}
|
|
464
|
-
} finally {
|
|
465
|
-
signal?.removeEventListener("abort", onAbort);
|
|
466
|
-
rl.close();
|
|
467
|
-
console.log(`[Cursor debug] ${tag} readJsonLines done: ${lineCount} raw lines, signalAborted=${signal?.aborted ?? false}`);
|
|
468
|
-
}
|
|
477
|
+
},
|
|
478
|
+
onParsedLine: () => {
|
|
479
|
+
if (stats) stats.parsedLineCount++;
|
|
480
|
+
},
|
|
481
|
+
onDone: (info) => {
|
|
482
|
+
console.log(`[Cursor debug] ${tag} readJsonLines done: ${info.lineCount} raw lines, signalAborted=${info.signalAborted}`);
|
|
483
|
+
},
|
|
484
|
+
});
|
|
469
485
|
}
|
|
470
486
|
|
|
471
487
|
// ---------------------------------------------------------------------------
|
|
@@ -479,11 +495,18 @@ class CursorAdapter implements ToolAdapter {
|
|
|
479
495
|
private metaStore: CursorSessionMetaStore;
|
|
480
496
|
private modelOverride: string | undefined;
|
|
481
497
|
private spawnImpl: CursorSpawn;
|
|
482
|
-
|
|
483
|
-
|
|
498
|
+
private badJsonIdleTimeoutMs: number | undefined;
|
|
499
|
+
|
|
500
|
+
constructor(
|
|
501
|
+
metaStore: CursorSessionMetaStore,
|
|
502
|
+
modelOverride?: string,
|
|
503
|
+
spawnImpl: CursorSpawn = spawn,
|
|
504
|
+
badJsonIdleTimeoutMs?: number,
|
|
505
|
+
) {
|
|
484
506
|
this.metaStore = metaStore;
|
|
485
507
|
this.modelOverride = modelOverride;
|
|
486
508
|
this.spawnImpl = spawnImpl;
|
|
509
|
+
this.badJsonIdleTimeoutMs = badJsonIdleTimeoutMs;
|
|
487
510
|
}
|
|
488
511
|
|
|
489
512
|
async createSession(cwd: string): Promise<CreateSessionResult> {
|
|
@@ -492,7 +515,7 @@ class CursorAdapter implements ToolAdapter {
|
|
|
492
515
|
const stats = createCursorStreamStats();
|
|
493
516
|
this.activeProcs.add(proc);
|
|
494
517
|
|
|
495
|
-
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)) {
|
|
496
519
|
if (msg.type === "system" && msg.subtype === "init" && msg.session_id) {
|
|
497
520
|
const sessionId = msg.session_id;
|
|
498
521
|
await this.metaStore
|
|
@@ -561,7 +584,7 @@ class CursorAdapter implements ToolAdapter {
|
|
|
561
584
|
const stats = createCursorStreamStats();
|
|
562
585
|
|
|
563
586
|
try {
|
|
564
|
-
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)) {
|
|
565
588
|
if (signal?.aborted) break;
|
|
566
589
|
if (
|
|
567
590
|
raw.type === "system" &&
|
|
@@ -634,6 +657,8 @@ export interface CreateCursorAdapterOptions {
|
|
|
634
657
|
model?: string;
|
|
635
658
|
/** 注入自定义 spawn 实现(测试用)。 */
|
|
636
659
|
spawn?: CursorSpawn;
|
|
660
|
+
/** Test-only override for the bad JSON idle watchdog threshold. */
|
|
661
|
+
badJsonIdleTimeoutMs?: number;
|
|
637
662
|
}
|
|
638
663
|
|
|
639
664
|
export function createCursorAdapter(
|
|
@@ -643,5 +668,6 @@ export function createCursorAdapter(
|
|
|
643
668
|
options.metaStore ?? defaultCursorSessionMetaStore,
|
|
644
669
|
options.model,
|
|
645
670
|
options.spawn,
|
|
671
|
+
options.badJsonIdleTimeoutMs,
|
|
646
672
|
);
|
|
647
673
|
}
|
|
@@ -0,0 +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,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
|
+
}
|
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
-
|
|
3
|
-
import { reloadRuntimeConfig, type RuntimeReloadResult } from "./runtime-reload.ts";
|
|
4
|
-
|
|
5
|
-
export const AGENT_RELOAD_CONFIG_PATH = "/api/agent/reload-config";
|
|
6
|
-
|
|
7
|
-
type ReloadRuntimeConfig = (source: string) => RuntimeReloadResult | Promise<RuntimeReloadResult>;
|
|
8
|
-
|
|
9
|
-
function jsonReply(res: ServerResponse, status: number, data: unknown): void {
|
|
10
|
-
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
11
|
-
res.end(JSON.stringify(data));
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export async function handleAgentReloadConfigRequest(
|
|
15
|
-
req: IncomingMessage,
|
|
16
|
-
res: ServerResponse,
|
|
17
|
-
reload: ReloadRuntimeConfig = reloadRuntimeConfig,
|
|
18
|
-
): Promise<boolean> {
|
|
19
|
-
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
20
|
-
if (url.pathname !== AGENT_RELOAD_CONFIG_PATH) return false;
|
|
21
|
-
|
|
22
|
-
if (req.method !== "POST") {
|
|
23
|
-
jsonReply(res, 405, { ok: false, error: "Method not allowed" });
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
try {
|
|
28
|
-
const result = await reload("agent-reload-api");
|
|
29
|
-
jsonReply(res, 200, { ok: true, ...result });
|
|
30
|
-
} catch (err) {
|
|
31
|
-
jsonReply(res, 500, { ok: false, error: (err as Error).message });
|
|
32
|
-
}
|
|
33
|
-
return true;
|
|
34
|
-
}
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
|
|
3
|
+
import { reloadRuntimeConfig, type RuntimeReloadResult } from "./runtime-reload.ts";
|
|
4
|
+
|
|
5
|
+
export const AGENT_RELOAD_CONFIG_PATH = "/api/agent/reload-config";
|
|
6
|
+
|
|
7
|
+
type ReloadRuntimeConfig = (source: string) => RuntimeReloadResult | Promise<RuntimeReloadResult>;
|
|
8
|
+
|
|
9
|
+
function jsonReply(res: ServerResponse, status: number, data: unknown): void {
|
|
10
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
11
|
+
res.end(JSON.stringify(data));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function handleAgentReloadConfigRequest(
|
|
15
|
+
req: IncomingMessage,
|
|
16
|
+
res: ServerResponse,
|
|
17
|
+
reload: ReloadRuntimeConfig = reloadRuntimeConfig,
|
|
18
|
+
): Promise<boolean> {
|
|
19
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
20
|
+
if (url.pathname !== AGENT_RELOAD_CONFIG_PATH) return false;
|
|
21
|
+
|
|
22
|
+
if (req.method !== "POST") {
|
|
23
|
+
jsonReply(res, 405, { ok: false, error: "Method not allowed" });
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const result = await reload("agent-reload-api");
|
|
29
|
+
jsonReply(res, 200, { ok: true, ...result });
|
|
30
|
+
} catch (err) {
|
|
31
|
+
jsonReply(res, 500, { ok: false, error: (err as Error).message });
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|