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.
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 +2 -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 -120
  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 +114 -1
  23. package/src/__tests__/feishu-avatar.test.ts +40 -40
  24. package/src/__tests__/jsonl-stream.test.ts +79 -0
  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 -392
  33. package/src/adapters/cursor-adapter.ts +56 -30
  34. package/src/adapters/jsonl-stream.ts +157 -0
  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
@@ -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 "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,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
- const rl = createInterface({ input: proc.stdout!, crlfDelay: Infinity });
443
- // abort 时主动 close readline,避免等待 Windows 管道自然关闭(可能延迟数分钟)
444
- const onAbort = () => { rl.close(); };
445
- signal?.addEventListener("abort", onAbort, { once: true });
446
- let lineCount = 0;
447
- try {
448
- for await (const line of rl) {
449
- if (signal?.aborted) break;
450
- lineCount++;
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
- const trimmed = line.trim();
456
- if (!trimmed) continue;
457
- rawLog?.writeLine(trimmed);
458
- try {
459
- const parsed = JSON.parse(trimmed) as CursorMessageLine;
460
- if (stats) stats.parsedLineCount++;
461
- yield parsed;
462
- } catch { /* 非 JSON 行静默跳过 */ }
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
- 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
+ ) {
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
+ }