chatccc 0.2.197 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.197",
3
+ "version": "0.2.198",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -36,6 +36,7 @@
36
36
  "demo:permission-check": "tsx demo/permission_check.ts",
37
37
  "demo:claude-hi": "tsx demo/claude_say_hi.ts",
38
38
  "demo:codex-hi": "tsx demo/codex_say_hi.ts",
39
+ "demo:codex-app-server-approval": "tsx demo/codex-app-server-approval/approval_demo.ts",
39
40
  "demo:ilink-echo": "tsx demo/ilink_echo_probe.ts",
40
41
  "claude-proxy": "tsx src/litellm-proxy.ts",
41
42
  "test": "vitest run",
@@ -23,6 +23,7 @@ vi.mock("../adapters/proc-tree-kill.ts", () => ({
23
23
 
24
24
  import { config } from "../config.ts";
25
25
  import { createCodexAdapter } from "../adapters/codex-adapter.ts";
26
+ import { BadJsonIdleTimeoutError } from "../adapters/jsonl-stream.ts";
26
27
  import type { CodexSessionMetaStore } from "../adapters/codex-session-meta-store.ts";
27
28
 
28
29
  const originalRawStreamLogs = structuredClone(config.rawStreamLogs);
@@ -52,6 +53,28 @@ function createProc(lines: string[]): EventEmitter & {
52
53
  return proc;
53
54
  }
54
55
 
56
+ function createHangingProc(lines: string[]): EventEmitter & {
57
+ stdout: PassThrough;
58
+ stderr: PassThrough;
59
+ stdin: PassThrough;
60
+ pid: number;
61
+ } {
62
+ const proc = new EventEmitter() as EventEmitter & {
63
+ stdout: PassThrough;
64
+ stderr: PassThrough;
65
+ stdin: PassThrough;
66
+ pid: number;
67
+ };
68
+ proc.stdout = new PassThrough();
69
+ proc.stderr = new PassThrough();
70
+ proc.stdin = new PassThrough();
71
+ proc.pid = 4242;
72
+ queueMicrotask(() => {
73
+ for (const line of lines) proc.stdout.write(`${line}\n`);
74
+ });
75
+ return proc;
76
+ }
77
+
55
78
  async function collect(iterable: AsyncIterable<unknown>): Promise<unknown[]> {
56
79
  const events: unknown[] = [];
57
80
  for await (const event of iterable) events.push(event);
@@ -69,6 +92,7 @@ function metaStore(): CodexSessionMetaStore {
69
92
  }
70
93
 
71
94
  afterEach(() => {
95
+ vi.useRealTimers();
72
96
  spawnMock.mockReset();
73
97
  createRawStreamLogMock.mockReset();
74
98
  rawLogWriteLineMock.mockReset();
@@ -117,4 +141,23 @@ describe("Codex raw stream logs", () => {
117
141
  blocks: [{ type: "text", text: "hello" }],
118
142
  });
119
143
  });
144
+
145
+ it("fails the turn and kills the process tree when bad JSON is followed by idle stdout", async () => {
146
+ vi.useFakeTimers();
147
+ spawnMock.mockReturnValueOnce(createHangingProc([
148
+ "{\"type\":\"item.started\",\"item\":{\"type\":\"command_execution\"",
149
+ ]));
150
+ createRawStreamLogMock.mockResolvedValueOnce(null);
151
+
152
+ const adapter = createCodexAdapter({ metaStore: metaStore() });
153
+ const pending = collect(adapter.prompt("sid-raw", "hi", "F:/project"))
154
+ .catch((error: unknown) => error);
155
+
156
+ await Promise.resolve();
157
+ await vi.advanceTimersByTimeAsync(120_000);
158
+
159
+ const error = await pending;
160
+ expect(error).toBeInstanceOf(BadJsonIdleTimeoutError);
161
+ expect(killProcessTreeMock).toHaveBeenCalledWith(4242);
162
+ });
120
163
  });
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { afterEach, describe, it, expect, vi } from "vitest";
2
2
  import { EventEmitter } from "node:events";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
@@ -10,6 +10,7 @@ import {
10
10
  createCursorAdapter,
11
11
  formatCursorAgentEmptyOutputMessage,
12
12
  } from "../adapters/cursor-adapter.ts";
13
+ import { BadJsonIdleTimeoutError } from "../adapters/jsonl-stream.ts";
13
14
  import type { UnifiedStreamMessage } from "../adapters/adapter-interface.ts";
14
15
  import type {
15
16
  CursorSessionMeta,
@@ -82,6 +83,33 @@ function createMockCursorProcess(args: {
82
83
  return child;
83
84
  }
84
85
 
86
+ function createHangingMockCursorProcess(args: {
87
+ stdout?: string;
88
+ stderr?: string;
89
+ }): ChildProcess {
90
+ const child = new EventEmitter() as ChildProcess;
91
+ const stdout = new PassThrough();
92
+ const stderr = new PassThrough();
93
+ const stdin = new PassThrough();
94
+ Object.assign(child, {
95
+ stdout,
96
+ stderr,
97
+ stdin,
98
+ pid: undefined,
99
+ });
100
+
101
+ setTimeout(() => {
102
+ if (args.stdout) stdout.write(args.stdout);
103
+ if (args.stderr) stderr.write(args.stderr);
104
+ }, 0);
105
+
106
+ return child;
107
+ }
108
+
109
+ afterEach(() => {
110
+ vi.useRealTimers();
111
+ });
112
+
85
113
  // ---------------------------------------------------------------------------
86
114
  // normalizeCursorMessage — 核心映射逻辑测试(纯函数)
87
115
  // ---------------------------------------------------------------------------
@@ -757,4 +785,30 @@ describe("Cursor adapter process failures", () => {
757
785
  /Cursor Agent exited without stream-json output/,
758
786
  );
759
787
  });
788
+
789
+ it("prompt fails when Cursor emits bad JSON and then leaves stdout idle", async () => {
790
+ vi.useFakeTimers();
791
+ const store = createInMemoryMetaStore({ sid: { cwd: "F:/repo" } });
792
+ const spawnImpl = (() =>
793
+ createHangingMockCursorProcess({
794
+ stdout: "{\"type\":\"tool_call\",\"subtype\":\"started\"\n",
795
+ })) as CursorSpawnForTest;
796
+ const adapter = createCursorAdapter({ metaStore: store, spawn: spawnImpl });
797
+
798
+ const pending = (async () => {
799
+ for await (const _event of adapter.prompt(
800
+ "sid",
801
+ "[User message]\nhello\n[/User message]",
802
+ "F:/repo",
803
+ )) {
804
+ // No normalized events are expected before the watchdog fires.
805
+ }
806
+ })().catch((error: unknown) => error);
807
+
808
+ await vi.advanceTimersByTimeAsync(0);
809
+ await vi.advanceTimersByTimeAsync(120_000);
810
+
811
+ const error = await pending;
812
+ expect(error).toBeInstanceOf(BadJsonIdleTimeoutError);
813
+ });
760
814
  });
@@ -0,0 +1,79 @@
1
+ import { PassThrough } from "node:stream";
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import {
6
+ BadJsonIdleTimeoutError,
7
+ readJsonLinesWithBadJsonIdleWatchdog,
8
+ } from "../adapters/jsonl-stream.ts";
9
+
10
+ function createReader(input: PassThrough, idleTimeoutMs = 100): AsyncIterator<unknown> {
11
+ return readJsonLinesWithBadJsonIdleWatchdog({
12
+ input,
13
+ tool: "test-agent",
14
+ tag: "sid-test",
15
+ idleTimeoutMs,
16
+ parse: (line) => JSON.parse(line) as unknown,
17
+ })[Symbol.asyncIterator]();
18
+ }
19
+
20
+ afterEach(() => {
21
+ vi.useRealTimers();
22
+ });
23
+
24
+ describe("readJsonLinesWithBadJsonIdleWatchdog", () => {
25
+ it("throws when a JSON-like bad line remains the last stdout past the idle timeout", async () => {
26
+ vi.useFakeTimers();
27
+ const input = new PassThrough();
28
+ const iterator = createReader(input);
29
+
30
+ const pending = iterator.next().catch((error: unknown) => error);
31
+ input.write("{\"type\":\"tool_call\",\"subtype\":\"started\"\n");
32
+
33
+ await vi.advanceTimersByTimeAsync(100);
34
+
35
+ const error = await pending;
36
+ expect(error).toBeInstanceOf(BadJsonIdleTimeoutError);
37
+ expect(error).toMatchObject({
38
+ code: "BAD_JSON_IDLE_TIMEOUT",
39
+ tool: "test-agent",
40
+ tag: "sid-test",
41
+ });
42
+ });
43
+
44
+ it("does not throw when a valid JSON line arrives after the bad line before timeout", async () => {
45
+ vi.useFakeTimers();
46
+ const input = new PassThrough();
47
+ const iterator = createReader(input);
48
+
49
+ const pending = iterator.next();
50
+ input.write("{\"type\":\"tool_call\"\n");
51
+ await vi.advanceTimersByTimeAsync(50);
52
+ input.write("{\"type\":\"ok\"}\n");
53
+
54
+ await expect(pending).resolves.toEqual({
55
+ done: false,
56
+ value: { type: "ok" },
57
+ });
58
+
59
+ const done = iterator.next();
60
+ input.end();
61
+ await expect(done).resolves.toEqual({ done: true, value: undefined });
62
+ });
63
+
64
+ it("ignores non-JSON banner lines for the bad JSON idle watchdog", async () => {
65
+ vi.useFakeTimers();
66
+ const input = new PassThrough();
67
+ const iterator = createReader(input);
68
+
69
+ const pending = iterator.next();
70
+ input.write("Reading prompt from stdin...\n");
71
+ await vi.advanceTimersByTimeAsync(200);
72
+ input.write("{\"type\":\"ok\"}\n");
73
+
74
+ await expect(pending).resolves.toEqual({
75
+ done: false,
76
+ value: { type: "ok" },
77
+ });
78
+ });
79
+ });
@@ -10,7 +10,6 @@
10
10
  import { spawn, type ChildProcess } from "node:child_process";
11
11
  import { existsSync, readFileSync } from "node:fs";
12
12
  import { dirname, join } from "node:path";
13
- import { createInterface } from "node:readline";
14
13
  import { randomUUID } from "node:crypto";
15
14
  import { fileURLToPath } from "node:url";
16
15
 
@@ -33,6 +32,7 @@ import {
33
32
  createRawStreamLog,
34
33
  type RawStreamLogHandle,
35
34
  } from "./raw-stream-log.ts";
35
+ import { readJsonLinesWithBadJsonIdleWatchdog } from "./jsonl-stream.ts";
36
36
 
37
37
  // ---------------------------------------------------------------------------
38
38
  // 特殊注入提示
@@ -237,26 +237,14 @@ async function* readJsonLines(
237
237
  signal?: AbortSignal,
238
238
  rawLog?: RawStreamLogHandle | null,
239
239
  ): AsyncGenerator<CodexEvent> {
240
- const rl = createInterface({ input: proc.stdout!, crlfDelay: Infinity });
241
- // abort 时主动 close readline,避免等待 Windows 管道自然关闭(可能延迟数分钟)
242
- const onAbort = () => { rl.close(); };
243
- signal?.addEventListener("abort", onAbort, { once: true });
244
- try {
245
- for await (const line of rl) {
246
- if (signal?.aborted) break;
247
- const trimmed = line.trim();
248
- if (!trimmed) continue;
249
- rawLog?.writeLine(trimmed);
250
- try {
251
- yield JSON.parse(trimmed) as CodexEvent;
252
- } catch {
253
- // 非 JSON 行静默跳过(如 "Reading prompt from stdin...")
254
- }
255
- }
256
- } finally {
257
- signal?.removeEventListener("abort", onAbort);
258
- rl.close();
259
- }
240
+ yield* readJsonLinesWithBadJsonIdleWatchdog<CodexEvent>({
241
+ input: proc.stdout!,
242
+ tool: "codex",
243
+ tag: "codex",
244
+ signal,
245
+ rawLog,
246
+ parse: (line) => JSON.parse(line) as CodexEvent,
247
+ });
260
248
  }
261
249
 
262
250
  // ---------------------------------------------------------------------------
@@ -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
  // 特殊注入提示
@@ -439,33 +439,26 @@ async function* readJsonLines(
439
439
  stats?: CursorStreamStats,
440
440
  ): AsyncGenerator<CursorMessageLine> {
441
441
  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++;
442
+ yield* readJsonLinesWithBadJsonIdleWatchdog<CursorMessageLine>({
443
+ input: proc.stdout!,
444
+ tool: "cursor",
445
+ tag,
446
+ signal,
447
+ rawLog,
448
+ parse: (line) => JSON.parse(line) as CursorMessageLine,
449
+ onRawLine: (line) => {
451
450
  if (stats) {
452
451
  stats.rawLineCount++;
453
452
  stats.stdoutLength += Buffer.byteLength(line, "utf-8") + 1;
454
453
  }
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
- }
454
+ },
455
+ onParsedLine: () => {
456
+ if (stats) stats.parsedLineCount++;
457
+ },
458
+ onDone: (info) => {
459
+ console.log(`[Cursor debug] ${tag} readJsonLines done: ${info.lineCount} raw lines, signalAborted=${info.signalAborted}`);
460
+ },
461
+ });
469
462
  }
470
463
 
471
464
  // ---------------------------------------------------------------------------
@@ -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
+ }