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.
Files changed (52) hide show
  1. package/agent-prompts/cursor_specific.md +13 -13
  2. package/bin/cccagent.mjs +17 -17
  3. package/config.sample.json +27 -27
  4. package/package.json +1 -1
  5. package/src/__tests__/agent-reload-config-rpc.test.ts +99 -99
  6. package/src/__tests__/builtin-chat-session.test.ts +277 -277
  7. package/src/__tests__/builtin-cli-json.test.ts +39 -39
  8. package/src/__tests__/builtin-config.test.ts +33 -33
  9. package/src/__tests__/builtin-context.test.ts +163 -163
  10. package/src/__tests__/builtin-file-tools.test.ts +224 -224
  11. package/src/__tests__/builtin-session-select.test.ts +116 -116
  12. package/src/__tests__/builtin-sigint.test.ts +56 -56
  13. package/src/__tests__/cards.test.ts +109 -109
  14. package/src/__tests__/ccc-adapter.test.ts +114 -114
  15. package/src/__tests__/chatgpt-subscription-rpc.test.ts +89 -89
  16. package/src/__tests__/chatgpt-subscription.test.ts +135 -135
  17. package/src/__tests__/chrome-devtools-guard.test.ts +165 -165
  18. package/src/__tests__/claude-raw-stream-log.test.ts +87 -87
  19. package/src/__tests__/codex-raw-stream-log.test.ts +163 -163
  20. package/src/__tests__/config-reload.test.ts +10 -10
  21. package/src/__tests__/config-sample.test.ts +18 -18
  22. package/src/__tests__/cursor-adapter.test.ts +66 -7
  23. package/src/__tests__/feishu-avatar.test.ts +40 -40
  24. package/src/__tests__/jsonl-stream.test.ts +79 -79
  25. package/src/__tests__/orchestrator.test.ts +200 -200
  26. package/src/__tests__/raw-stream-log.test.ts +106 -106
  27. package/src/__tests__/session.test.ts +40 -40
  28. package/src/__tests__/sim-platform.test.ts +12 -12
  29. package/src/__tests__/web-ui.test.ts +209 -209
  30. package/src/adapters/ccc-adapter.ts +121 -121
  31. package/src/adapters/claude-adapter.ts +603 -603
  32. package/src/adapters/codex-adapter.ts +380 -380
  33. package/src/adapters/cursor-adapter.ts +39 -6
  34. package/src/adapters/jsonl-stream.ts +157 -157
  35. package/src/adapters/raw-stream-log.ts +124 -124
  36. package/src/agent-reload-config-rpc.ts +34 -34
  37. package/src/builtin/cli.ts +473 -473
  38. package/src/builtin/context.ts +323 -323
  39. package/src/builtin/file-tools.ts +1072 -1072
  40. package/src/builtin/index.ts +404 -404
  41. package/src/builtin/session-select.ts +48 -48
  42. package/src/builtin/sigint.ts +50 -50
  43. package/src/cards.ts +195 -195
  44. package/src/chatgpt-subscription-rpc.ts +27 -27
  45. package/src/chatgpt-subscription.ts +299 -299
  46. package/src/chrome-devtools-guard.ts +318 -318
  47. package/src/config.ts +125 -125
  48. package/src/feishu-api.ts +49 -49
  49. package/src/orchestrator.ts +179 -179
  50. package/src/runtime-reload.ts +34 -34
  51. package/src/session.ts +141 -141
  52. 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 "Cursor Agent 没有返回内容。检测到认证相关错误,可能需要重新登录 Cursor Agent,或配置 CURSOR_API_KEY。请在本机运行 agent status 检查状态;如未登录,请运行 agent login 后重试。";
190
+ return `Cursor Agent 没有返回内容。检测到认证相关错误,可能需要重新登录 Cursor Agent,或配置 CURSOR_API_KEY。请在本机运行 agent status 检查状态;如未登录,请运行 agent login 后重试。\n\n${stderrBlock}`;
170
191
  }
171
192
 
172
- return "Cursor Agent 没有返回内容。底层命令异常退出,请检查本机 Cursor Agent 状态后重试。";
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
- constructor(metaStore: CursorSessionMetaStore, modelOverride?: string, spawnImpl: CursorSpawn = spawn) {
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
+ }