chatccc 0.2.230 → 0.2.231

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 (116) hide show
  1. package/.agents/skills/create-chatccc-feishu-app/SKILL.md +85 -85
  2. package/.claude/skills/create-chatccc-feishu-app/SKILL.md +85 -85
  3. package/.cursor/skills/create-chatccc-feishu-app/SKILL.md +85 -85
  4. package/README.md +90 -90
  5. package/agent-prompts/claude_specific.md +45 -45
  6. package/agent-prompts/codex_specific.md +2 -2
  7. package/agent-prompts/cursor_specific.md +13 -13
  8. package/bin/cccagent.mjs +17 -17
  9. package/im-skills/feishu-skill/receive-send-file.md +63 -63
  10. package/im-skills/feishu-skill/receive-send-image.md +24 -24
  11. package/im-skills/feishu-skill/skill.md +3 -3
  12. package/im-skills/wechat-file-skill/receive-send-file.md +38 -38
  13. package/im-skills/wechat-file-skill/send-file.mjs +83 -83
  14. package/im-skills/wechat-file-skill/skill.md +10 -10
  15. package/im-skills/wechat-image-skill/skill.md +10 -10
  16. package/im-skills/wechat-video-skill/receive-send-video.md +38 -38
  17. package/im-skills/wechat-video-skill/send-video.mjs +79 -79
  18. package/im-skills/wechat-video-skill/skill.md +10 -10
  19. package/package.json +1 -1
  20. package/scripts/postinstall-sharp-check.mjs +58 -58
  21. package/src/__tests__/agent-activity.test.ts +86 -76
  22. package/src/__tests__/agent-delegate-task-rpc.test.ts +165 -165
  23. package/src/__tests__/agent-reload-config-rpc.test.ts +99 -99
  24. package/src/__tests__/builtin-chat-session.test.ts +65 -1
  25. package/src/__tests__/builtin-cli-json.test.ts +39 -39
  26. package/src/__tests__/builtin-config.test.ts +26 -26
  27. package/src/__tests__/builtin-context.test.ts +202 -163
  28. package/src/__tests__/builtin-permissions.test.ts +211 -211
  29. package/src/__tests__/builtin-session-select.test.ts +116 -116
  30. package/src/__tests__/builtin-sigint.test.ts +56 -56
  31. package/src/__tests__/builtin-web-tools.test.ts +220 -220
  32. package/src/__tests__/card-action-routing.test.ts +18 -18
  33. package/src/__tests__/cardkit.test.ts +60 -60
  34. package/src/__tests__/ccc-adapter.test.ts +170 -136
  35. package/src/__tests__/chatgpt-subscription-rpc.test.ts +89 -89
  36. package/src/__tests__/chatgpt-subscription.test.ts +135 -135
  37. package/src/__tests__/chrome-devtools-guard.test.ts +165 -165
  38. package/src/__tests__/claude-adapter.test.ts +614 -614
  39. package/src/__tests__/claude-raw-stream-log.test.ts +87 -87
  40. package/src/__tests__/codex-adapter.test.ts +58 -58
  41. package/src/__tests__/codex-raw-stream-log.test.ts +170 -170
  42. package/src/__tests__/codex-reset-actions.test.ts +146 -146
  43. package/src/__tests__/cursor-adapter.test.ts +268 -268
  44. package/src/__tests__/feishu-api.test.ts +60 -60
  45. package/src/__tests__/feishu-avatar.test.ts +164 -164
  46. package/src/__tests__/feishu-message-ingress.test.ts +138 -138
  47. package/src/__tests__/feishu-platform.test.ts +22 -22
  48. package/src/__tests__/format-message.test.ts +47 -47
  49. package/src/__tests__/jsonl-stream.test.ts +79 -79
  50. package/src/__tests__/package-files.test.ts +24 -24
  51. package/src/__tests__/privacy.test.ts +198 -198
  52. package/src/__tests__/progress-reducer.test.ts +121 -110
  53. package/src/__tests__/raw-stream-log.test.ts +106 -106
  54. package/src/__tests__/response-stall.test.ts +49 -49
  55. package/src/__tests__/session.test.ts +11 -16
  56. package/src/__tests__/shared-prefix.test.ts +36 -36
  57. package/src/__tests__/sim-platform.test.ts +16 -16
  58. package/src/__tests__/startup-lifecycle.test.ts +231 -231
  59. package/src/__tests__/stop-session.test.ts +34 -34
  60. package/src/__tests__/stream-state.test.ts +42 -42
  61. package/src/__tests__/terminal-renderer.test.ts +247 -247
  62. package/src/__tests__/update-command-guard.test.ts +144 -144
  63. package/src/__tests__/web-ui.test.ts +326 -326
  64. package/src/adapters/adapter-interface.ts +24 -18
  65. package/src/adapters/ccc-adapter.ts +141 -131
  66. package/src/adapters/claude-adapter.ts +620 -620
  67. package/src/adapters/claude-session-meta-store.ts +120 -120
  68. package/src/adapters/codex-adapter.ts +426 -426
  69. package/src/adapters/cursor-adapter.ts +681 -681
  70. package/src/adapters/jsonl-stream.ts +157 -157
  71. package/src/adapters/raw-stream-log.ts +124 -124
  72. package/src/adapters/resource-monitor.ts +140 -140
  73. package/src/agent-activity.ts +175 -170
  74. package/src/agent-delegate-task-rpc.ts +153 -153
  75. package/src/agent-delegate-task.ts +91 -91
  76. package/src/agent-reload-config-rpc.ts +34 -34
  77. package/src/agent-stop-stuck.ts +129 -129
  78. package/src/builtin/cli.ts +9 -2
  79. package/src/builtin/config.ts +84 -84
  80. package/src/builtin/context.ts +62 -4
  81. package/src/builtin/file-log.ts +38 -38
  82. package/src/builtin/index.ts +83 -19
  83. package/src/builtin/proc-tree-kill.ts +61 -61
  84. package/src/builtin/progress/cards-helpers.ts +76 -76
  85. package/src/builtin/progress/reducer.ts +113 -108
  86. package/src/builtin/progress/terminal-renderer.ts +294 -294
  87. package/src/builtin/progress/view.ts +77 -77
  88. package/src/builtin/raw-stream-log.ts +124 -124
  89. package/src/builtin/session-select.ts +48 -48
  90. package/src/builtin/sigint.ts +50 -50
  91. package/src/builtin/web-tools.ts +313 -313
  92. package/src/card-action-routing.ts +14 -14
  93. package/src/chatgpt-subscription-rpc.ts +27 -27
  94. package/src/chatgpt-subscription.ts +299 -299
  95. package/src/chrome-devtools-guard.ts +318 -318
  96. package/src/codex-reset-actions.ts +184 -184
  97. package/src/feishu-api.ts +193 -193
  98. package/src/feishu-message-ingress.ts +195 -195
  99. package/src/feishu-platform.ts +20 -20
  100. package/src/format-message.ts +293 -293
  101. package/src/litellm-proxy.ts +374 -374
  102. package/src/platform-adapter.ts +6 -6
  103. package/src/privacy.ts +118 -118
  104. package/src/progress/reducer.ts +113 -108
  105. package/src/progress/terminal-renderer.ts +294 -294
  106. package/src/progress/view.ts +77 -77
  107. package/src/response-stall.ts +28 -28
  108. package/src/runtime-reload.ts +34 -34
  109. package/src/session-chat-binding.ts +82 -82
  110. package/src/session-name.ts +8 -8
  111. package/src/session.ts +12 -11
  112. package/src/shared-prefix.ts +29 -29
  113. package/src/sim-platform.ts +20 -20
  114. package/src/startup-lifecycle.ts +250 -250
  115. package/src/turn-cards.ts +117 -117
  116. package/src/update-command-guard.ts +165 -165
@@ -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
+ }