chatccc 0.2.196 → 0.2.198

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 (53) 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 -0
  6. package/src/__tests__/builtin-chat-session.test.ts +277 -181
  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 -196
  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 -113
  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 -0
  19. package/src/__tests__/codex-raw-stream-log.test.ts +163 -0
  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 +167 -113
  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 +181 -154
  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 -119
  31. package/src/adapters/claude-adapter.ts +603 -566
  32. package/src/adapters/codex-adapter.ts +65 -52
  33. package/src/adapters/cursor-adapter.ts +269 -276
  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 -0
  37. package/src/builtin/cli.ts +473 -461
  38. package/src/builtin/context.ts +323 -323
  39. package/src/builtin/file-tools.ts +1072 -915
  40. package/src/builtin/index.ts +404 -353
  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/index.ts +8 -13
  50. package/src/orchestrator.ts +166 -145
  51. package/src/runtime-reload.ts +34 -0
  52. package/src/session.ts +141 -141
  53. package/src/web-ui.ts +205 -205
@@ -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
+ }
@@ -0,0 +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
+ }