chatccc 0.2.272 → 0.2.276

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 (35) hide show
  1. package/README.md +24 -22
  2. package/config.sample.json +4 -4
  3. package/deepccc-agent/README.md +147 -79
  4. package/deepccc-agent/package.json +68 -65
  5. package/dist/deepccc-agent/src/attachments.js +192 -0
  6. package/dist/deepccc-agent/src/cli.js +54 -13
  7. package/dist/deepccc-agent/src/config.js +49 -4
  8. package/dist/deepccc-agent/src/context.js +299 -16
  9. package/dist/deepccc-agent/src/file-tools.js +33 -0
  10. package/dist/deepccc-agent/src/index.js +48 -16
  11. package/dist/deepccc-agent/src/tool-protocol.js +14 -3
  12. package/dist/deepccc-agent/src/web-entry.js +72 -0
  13. package/dist/deepccc-agent/src/web-page.js +414 -0
  14. package/dist/deepccc-agent/src/web-runtime.js +331 -0
  15. package/dist/deepccc-agent/src/web-server.js +476 -0
  16. package/dist/deepccc-agent/src/web-session-store.js +162 -0
  17. package/dist/deepccc-agent/src/web-tool-presentation.js +123 -0
  18. package/dist/src/adapters/ccc-adapter.js +1 -0
  19. package/dist/src/agent-capability-grants.js +26 -0
  20. package/dist/src/agent-delegate-task.js +5 -2
  21. package/dist/src/agent-file-rpc.js +6 -1
  22. package/dist/src/agent-image-rpc.js +6 -1
  23. package/dist/src/agent-team/web/agent-team-page.js +250 -250
  24. package/dist/src/cards.js +7 -4
  25. package/dist/src/im-skills.js +9 -2
  26. package/dist/src/orchestrator.js +117 -29
  27. package/dist/src/session-name.js +15 -0
  28. package/dist/src/session.js +43 -7
  29. package/dist/src/web-ui.js +68 -51
  30. package/im-skills/feishu-skill/receive-send-file.md +3 -2
  31. package/im-skills/feishu-skill/receive-send-image.md +3 -2
  32. package/im-skills/feishu-skill/send-file.mjs +6 -5
  33. package/im-skills/feishu-skill/send-image.mjs +6 -5
  34. package/im-skills/feishu-skill/skill.md +4 -2
  35. package/package.json +76 -76
@@ -0,0 +1,192 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { DEEPCCC_HOME } from "./config.js";
5
+ import { normalizeBuiltinSessionId } from "./context.js";
6
+ export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
7
+ export const MAX_ATTACHMENTS_PER_MESSAGE = 10;
8
+ export const ATTACHMENT_PROMPT_START = "<deepccc-attachments>";
9
+ export const ATTACHMENT_PROMPT_END = "</deepccc-attachments>";
10
+ export class AttachmentStore {
11
+ rootDir;
12
+ idFactory;
13
+ constructor(options = {}) {
14
+ this.rootDir = resolve(options.rootDir ?? join(DEEPCCC_HOME, "attachments"));
15
+ this.idFactory = options.idFactory ?? (() => randomUUID());
16
+ }
17
+ async save(sessionId, input) {
18
+ const bytes = Buffer.from(input.bytes);
19
+ if (!bytes.length)
20
+ throw new Error("Image attachment must not be empty");
21
+ if (bytes.length > MAX_ATTACHMENT_BYTES)
22
+ throw new Error("Image attachment exceeds the 20 MB limit");
23
+ const mimeType = detectImageMime(bytes);
24
+ if (!mimeType)
25
+ throw new Error("Only PNG, JPEG, or WebP image attachments are supported");
26
+ const normalizedSessionId = normalizeBuiltinSessionId(sessionId);
27
+ const attachmentId = normalizeAttachmentId(this.idFactory());
28
+ const extension = extensionForMime(mimeType);
29
+ const fileName = `${attachmentId}${extension}`;
30
+ const sessionDir = this.sessionDir(normalizedSessionId);
31
+ const absolutePath = join(sessionDir, fileName);
32
+ const meta = {
33
+ attachmentId,
34
+ originalName: cleanOriginalName(input.originalName, extension),
35
+ mimeType,
36
+ size: bytes.length,
37
+ absolutePath,
38
+ fileName,
39
+ };
40
+ await mkdir(sessionDir, { recursive: true });
41
+ const dataTemp = `${absolutePath}.${process.pid}.tmp`;
42
+ const metaPath = this.metaPath(normalizedSessionId, attachmentId);
43
+ const metaTemp = `${metaPath}.${process.pid}.tmp`;
44
+ await writeFile(dataTemp, bytes);
45
+ await rename(dataTemp, absolutePath);
46
+ await writeFile(metaTemp, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
47
+ await rename(metaTemp, metaPath);
48
+ return publicMeta(meta);
49
+ }
50
+ async importFile(sessionId, path) {
51
+ const absolutePath = resolve(path);
52
+ const info = await stat(absolutePath).catch(() => null);
53
+ if (!info?.isFile())
54
+ throw new Error(`Image attachment not found: ${path}`);
55
+ if (info.size > MAX_ATTACHMENT_BYTES)
56
+ throw new Error(`Image attachment exceeds the 20 MB limit: ${path}`);
57
+ return this.save(sessionId, {
58
+ originalName: basename(absolutePath),
59
+ bytes: await readFile(absolutePath),
60
+ });
61
+ }
62
+ async get(sessionId, attachmentId) {
63
+ const stored = await this.readStored(sessionId, attachmentId);
64
+ return stored ? publicMeta(stored) : null;
65
+ }
66
+ async read(sessionId, attachmentId) {
67
+ const stored = await this.readStored(sessionId, attachmentId);
68
+ if (!stored)
69
+ return null;
70
+ try {
71
+ const bytes = await readFile(stored.absolutePath);
72
+ if (bytes.length !== stored.size || detectImageMime(bytes) !== stored.mimeType)
73
+ return null;
74
+ return { attachment: publicMeta(stored), bytes };
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ async delete(sessionId, attachmentId) {
81
+ const stored = await this.readStored(sessionId, attachmentId);
82
+ if (!stored)
83
+ return false;
84
+ await Promise.all([
85
+ unlink(stored.absolutePath).catch(() => { }),
86
+ unlink(this.metaPath(sessionId, attachmentId)).catch(() => { }),
87
+ ]);
88
+ return true;
89
+ }
90
+ async deleteSession(sessionId) {
91
+ await rm(this.sessionDir(sessionId), { recursive: true, force: true });
92
+ }
93
+ async readStored(sessionId, attachmentId) {
94
+ const normalizedId = normalizeAttachmentId(attachmentId);
95
+ try {
96
+ const value = JSON.parse(await readFile(this.metaPath(sessionId, normalizedId), "utf8"));
97
+ if (value.attachmentId !== normalizedId || typeof value.fileName !== "string")
98
+ return null;
99
+ if (typeof value.originalName !== "string" || typeof value.size !== "number")
100
+ return null;
101
+ if (value.mimeType !== "image/png" && value.mimeType !== "image/jpeg" && value.mimeType !== "image/webp")
102
+ return null;
103
+ if (value.fileName !== `${normalizedId}${extensionForMime(value.mimeType)}`)
104
+ return null;
105
+ const absolutePath = join(this.sessionDir(sessionId), value.fileName);
106
+ return { ...value, absolutePath };
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ sessionDir(sessionId) {
113
+ return join(this.rootDir, normalizeBuiltinSessionId(sessionId));
114
+ }
115
+ metaPath(sessionId, attachmentId) {
116
+ return join(this.sessionDir(sessionId), `${normalizeAttachmentId(attachmentId)}.json`);
117
+ }
118
+ }
119
+ export function buildAttachmentPrompt(text, attachments) {
120
+ const prompt = text.trim() || "请分析这些图片。";
121
+ if (!attachments.length)
122
+ return prompt;
123
+ const manifest = attachments.map((attachment) => ({
124
+ attachmentId: attachment.attachmentId,
125
+ originalName: attachment.originalName,
126
+ mimeType: attachment.mimeType,
127
+ size: attachment.size,
128
+ absolutePath: attachment.absolutePath,
129
+ }));
130
+ return [
131
+ prompt,
132
+ "",
133
+ ATTACHMENT_PROMPT_START,
134
+ JSON.stringify(manifest),
135
+ ATTACHMENT_PROMPT_END,
136
+ "以上图片以本地附件文件提供。不要假设模型原生支持图片;请使用可用工具读取这些绝对路径并自行处理。",
137
+ ].join("\n");
138
+ }
139
+ export function parseAttachmentPrompt(content) {
140
+ const start = content.indexOf(ATTACHMENT_PROMPT_START);
141
+ const end = content.indexOf(ATTACHMENT_PROMPT_END, start + ATTACHMENT_PROMPT_START.length);
142
+ if (start < 0 || end < 0)
143
+ return { text: content, attachments: [] };
144
+ const raw = content.slice(start + ATTACHMENT_PROMPT_START.length, end).trim();
145
+ let attachments = [];
146
+ try {
147
+ const parsed = JSON.parse(raw);
148
+ if (Array.isArray(parsed))
149
+ attachments = parsed.filter(isAttachmentMeta);
150
+ }
151
+ catch {
152
+ return { text: content, attachments: [] };
153
+ }
154
+ return { text: content.slice(0, start).trimEnd(), attachments };
155
+ }
156
+ export function detectImageMime(bytes) {
157
+ const buffer = Buffer.from(bytes);
158
+ if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
159
+ return "image/png";
160
+ if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff)
161
+ return "image/jpeg";
162
+ if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP")
163
+ return "image/webp";
164
+ return null;
165
+ }
166
+ function extensionForMime(mimeType) {
167
+ return mimeType === "image/png" ? ".png" : mimeType === "image/jpeg" ? ".jpg" : ".webp";
168
+ }
169
+ function cleanOriginalName(value, extension) {
170
+ const cleaned = basename(value || `image${extension}`).replace(/[\u0000-\u001f<>:"/\\|?*]+/g, "_").slice(0, 180);
171
+ return cleaned || `image${extension}`;
172
+ }
173
+ function normalizeAttachmentId(value) {
174
+ const normalized = value.replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
175
+ if (!normalized || normalized === "." || normalized === "..")
176
+ throw new Error("Invalid attachment id");
177
+ return normalized;
178
+ }
179
+ function publicMeta(value) {
180
+ const { fileName: _, ...meta } = value;
181
+ return meta;
182
+ }
183
+ function isAttachmentMeta(value) {
184
+ if (!value || typeof value !== "object" || Array.isArray(value))
185
+ return false;
186
+ const attachment = value;
187
+ return typeof attachment.attachmentId === "string"
188
+ && typeof attachment.originalName === "string"
189
+ && typeof attachment.absolutePath === "string"
190
+ && typeof attachment.size === "number"
191
+ && (attachment.mimeType === "image/png" || attachment.mimeType === "image/jpeg" || attachment.mimeType === "image/webp");
192
+ }
@@ -2,9 +2,9 @@
2
2
  * DeepCCC terminal REPL and JSONL streaming entrypoint — 同步自 ChatCCC
3
3
  *
4
4
  * Usage:
5
- * node bin/deepccc.mjs
6
- * node bin/deepccc.mjs --model deepseek-v4-pro
7
- * node bin/deepccc.mjs --stream-json --prompt "hello"
5
+ * node bin/deepccc-cli.mjs
6
+ * node bin/deepccc-cli.mjs --model deepseek-v4-pro
7
+ * node bin/deepccc-cli.mjs --stream-json --prompt "hello"
8
8
  *
9
9
  * 交互模式(TTY)下,单轮回复渲染为固定"过程区块":状态行 + 折叠工具行 +
10
10
  * 原地更新正文,不再滚屏刷 JSON;完成/停止/异常后定型留在屏幕上。
@@ -24,6 +24,7 @@ import { reduceProgress } from "./progress/reducer.js";
24
24
  import { TerminalProgressRenderer } from "./progress/terminal-renderer.js";
25
25
  import { progressView } from "./progress/view.js";
26
26
  import { defaultLogDir, setupFileLogging } from "./file-log.js";
27
+ import { AttachmentStore, buildAttachmentPrompt } from "./attachments.js";
27
28
  function parsePositiveIntegerOption(name, value) {
28
29
  const parsed = Number(value);
29
30
  if (!Number.isInteger(parsed) || parsed <= 0) {
@@ -46,6 +47,7 @@ function parseArgs(argv = process.argv.slice(2)) {
46
47
  let streamJson = false;
47
48
  let prompt = null;
48
49
  let plain = false;
50
+ const images = [];
49
51
  for (let i = 0; i < argv.length; i++) {
50
52
  const arg = argv[i];
51
53
  const next = argv[i + 1];
@@ -104,6 +106,10 @@ function parseArgs(argv = process.argv.slice(2)) {
104
106
  prompt = next;
105
107
  i++;
106
108
  }
109
+ else if (arg === "--image" && next !== undefined) {
110
+ images.push(next);
111
+ i++;
112
+ }
107
113
  else if (arg === "--plain") {
108
114
  plain = true;
109
115
  }
@@ -115,7 +121,7 @@ function parseArgs(argv = process.argv.slice(2)) {
115
121
  help = true;
116
122
  }
117
123
  }
118
- return { config, options, listSessions, resume, help, streamJson, prompt, plain };
124
+ return { config, options, listSessions, resume, help, streamJson, prompt, images, plain };
119
125
  }
120
126
  async function loadRuntime() {
121
127
  const [{ ChatSession }, { config: appConfig }] = await Promise.all([
@@ -128,7 +134,7 @@ function printHelp(appConfig) {
128
134
  console.log([
129
135
  "DeepCCC terminal agent",
130
136
  "",
131
- "Usage: deepccc [options]",
137
+ "Usage: deepccc-cli [options]",
132
138
  "",
133
139
  "Options:",
134
140
  ` --provider <name> API protocol: openai or anthropic (current default ${appConfig.provider})`,
@@ -144,6 +150,7 @@ function printHelp(appConfig) {
144
150
  " --list-sessions List saved sessions and exit",
145
151
  " --stream-json One-shot mode: write JSONL events to stdout",
146
152
  " --prompt <text> Prompt text for --stream-json",
153
+ " --image <path> Attach a PNG/JPEG/WebP file (repeatable; copied to the session attachment store)",
147
154
  " --plain Force plain streaming output (no progress block renderer)",
148
155
  " --dangerously-bypass-permissions Skip all permission prompts (aligns with chatccc's bypass mode)",
149
156
  " --help, -h Show help",
@@ -276,8 +283,8 @@ async function runStreamJson(args) {
276
283
  return 0;
277
284
  }
278
285
  const prompt = args.prompt ?? (!process.stdin.isTTY ? await readPromptFromStdin() : "");
279
- if (!prompt.trim()) {
280
- writeJsonLine({ type: "error", message: "--stream-json requires --prompt <text> or stdin input" });
286
+ if (!prompt.trim() && !args.images.length) {
287
+ writeJsonLine({ type: "error", message: "--stream-json requires --prompt <text>, stdin input, or --image <path>" });
281
288
  return 1;
282
289
  }
283
290
  let runtime;
@@ -310,15 +317,29 @@ async function runStreamJson(args) {
310
317
  writeJsonLine({ type: "error", message: err.message });
311
318
  return 1;
312
319
  }
320
+ let attachments;
321
+ try {
322
+ attachments = await importCliAttachments(resolvedSession.sessionId, args.images);
323
+ }
324
+ catch (err) {
325
+ writeJsonLine({ type: "error", message: err.message });
326
+ return 1;
327
+ }
328
+ const promptWithAttachments = buildAttachmentPrompt(prompt, attachments);
313
329
  writeJsonLine({
314
330
  type: "start",
315
331
  session_id: resolvedSession.sessionId,
316
332
  mode: resolvedSession.mode,
317
333
  cwd,
318
334
  model: args.config.model ?? runtime.appConfig.model,
335
+ attachments: attachments.map((attachment) => ({
336
+ attachment_id: attachment.attachmentId,
337
+ name: attachment.originalName,
338
+ path: attachment.absolutePath,
339
+ })),
319
340
  });
320
341
  try {
321
- for await (const event of session.chat(prompt)) {
342
+ for await (const event of session.chat(promptWithAttachments)) {
322
343
  streamJsonEvent(event);
323
344
  }
324
345
  return 0;
@@ -425,13 +446,24 @@ async function runRepl(args) {
425
446
  console.error(`${C.yellow}${err.message}${C.reset}`);
426
447
  process.exit(1);
427
448
  }
449
+ let pendingAttachments;
450
+ try {
451
+ pendingAttachments = await importCliAttachments(resolvedSession.sessionId, args.images);
452
+ }
453
+ catch (err) {
454
+ console.error(`${C.yellow}${err.message}${C.reset}`);
455
+ process.exit(1);
456
+ }
457
+ if (pendingAttachments.length) {
458
+ console.log(`${C.dim}Attachments queued: ${pendingAttachments.map((attachment) => attachment.originalName).join(", ")}${C.reset}`);
459
+ }
428
460
  let currentAbort = null;
429
461
  const ctrlCState = createCtrlCState();
430
462
  rl.prompt();
431
463
  rl.on("line", async (line) => {
432
464
  ctrlCState.reset();
433
465
  const input = line.trim();
434
- if (!input) {
466
+ if (!input && !pendingAttachments.length) {
435
467
  rl.prompt();
436
468
  return;
437
469
  }
@@ -475,8 +507,10 @@ async function runRepl(args) {
475
507
  }
476
508
  let rendererEnded = false;
477
509
  try {
510
+ const chatInput = buildAttachmentPrompt(input, pendingAttachments);
511
+ pendingAttachments = [];
478
512
  let lastAccumulated = "";
479
- for await (const event of session.chat(input, signal)) {
513
+ for await (const event of session.chat(chatInput, signal)) {
480
514
  if (renderer && view) {
481
515
  view = reduceProgress(view, event);
482
516
  if (event.type === "text" || event.type === "compact" || event.type === "status") {
@@ -564,8 +598,15 @@ async function runRepl(args) {
564
598
  process.exit(0);
565
599
  });
566
600
  }
601
+ async function importCliAttachments(sessionId, paths) {
602
+ const store = new AttachmentStore();
603
+ const attachments = [];
604
+ for (const path of paths)
605
+ attachments.push(await store.importFile(sessionId, path));
606
+ return attachments;
607
+ }
567
608
  /**
568
- * skill create 子命令:deepccc skill create <name> [--scope global|project] [--description "..."]
609
+ * skill create 子命令:deepccc-cli skill create <name> [--scope global|project] [--description "..."]
569
610
  * 默认创建为全局技能(~/.deepccc/skills/<name>/SKILL.md,Codex 结构);
570
611
  * --scope project 创建为项目技能(<cwd>/.deepccc/skills/<name>/SKILL.md)。
571
612
  * 新技能在下一次对话自动生效(技能索引每次 chat() 前重扫)。
@@ -574,7 +615,7 @@ function runSkillCreate(argv) {
574
615
  const positional = argv.filter((a) => !a.startsWith("--"));
575
616
  const name = positional[0];
576
617
  if (!name) {
577
- console.error("usage: deepccc skill create <name> [--scope global|project] [--description \"...\"]");
618
+ console.error("usage: deepccc-cli skill create <name> [--scope global|project] [--description \"...\"]");
578
619
  process.exit(1);
579
620
  }
580
621
  if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) {
@@ -608,7 +649,7 @@ function runSkillCreate(argv) {
608
649
  console.log("hot reload: 下一次对话自动生效,无需重启");
609
650
  }
610
651
  async function main() {
611
- // skill create 子命令:deepccc skill create <name> [--scope global|project] [--description "..."]
652
+ // skill create 子命令:deepccc-cli skill create <name> [--scope global|project] [--description "..."]
612
653
  // 默认创建在全局 ~/.deepccc/skills(Codex 目录结构),--scope project 创建到 <cwd>/.deepccc/skills。
613
654
  if (process.argv[2] === "skill" && process.argv[3] === "create") {
614
655
  runSkillCreate(process.argv.slice(4));
@@ -1,9 +1,9 @@
1
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  export const DEEPCCC_HOME = join(homedir(), ".deepccc");
5
5
  export const RAW_STREAM_LOGS_DIR = join(DEEPCCC_HOME, "raw-stream-logs");
6
- const CONFIG_PATH = join(DEEPCCC_HOME, "config.json");
6
+ export const CONFIG_PATH = join(DEEPCCC_HOME, "config.json");
7
7
  /**
8
8
  * 默认配置(不读环境/文件)。导出供测试断言默认值;运行时请用 loadConfig 结果。
9
9
  */
@@ -31,8 +31,12 @@ export const DEFAULT_CONFIG = {
31
31
  retentionDays: 7,
32
32
  keepCompleted: false,
33
33
  },
34
+ web: {
35
+ port: 28_080,
36
+ openOnStart: true,
37
+ },
34
38
  };
35
- function readConfigFile() {
39
+ export function readConfigFile() {
36
40
  if (!existsSync(CONFIG_PATH))
37
41
  return {};
38
42
  const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
@@ -70,13 +74,14 @@ export function normalizeDeepCccProvider(value) {
70
74
  return normalized;
71
75
  throw new Error(`DEEPCCC_PROVIDER/provider must be "openai" or "anthropic", received: ${String(value)}`);
72
76
  }
73
- function loadConfig() {
77
+ export function loadConfig() {
74
78
  const file = readConfigFile();
75
79
  const rawLogs = file.rawStreamLogs && typeof file.rawStreamLogs === "object"
76
80
  ? file.rawStreamLogs
77
81
  : {};
78
82
  const git = file.git && typeof file.git === "object" ? file.git : {};
79
83
  const coAuthor = git.coAuthor && typeof git.coAuthor === "object" ? git.coAuthor : {};
84
+ const web = file.web && typeof file.web === "object" ? file.web : {};
80
85
  return {
81
86
  provider: normalizeDeepCccProvider(env("DEEPCCC_PROVIDER") ?? file.provider ?? DEFAULT_CONFIG.provider),
82
87
  apiKey: env("DEEPCCC_API_KEY") ?? env("DEEPSEEK_API_KEY") ?? file.apiKey ?? DEFAULT_CONFIG.apiKey,
@@ -101,9 +106,49 @@ function loadConfig() {
101
106
  retentionDays: numberEnv("DEEPCCC_RAW_STREAM_RETENTION_DAYS") ?? rawLogs.retentionDays ?? DEFAULT_CONFIG.rawStreamLogs.retentionDays,
102
107
  keepCompleted: boolEnv("DEEPCCC_RAW_STREAM_KEEP_COMPLETED") ?? rawLogs.keepCompleted ?? DEFAULT_CONFIG.rawStreamLogs.keepCompleted,
103
108
  },
109
+ web: {
110
+ port: normalizeWebPort(web.port),
111
+ openOnStart: web.openOnStart ?? DEFAULT_CONFIG.web.openOnStart,
112
+ },
104
113
  };
105
114
  }
106
115
  export function ensureConfigDir() {
107
116
  mkdirSync(dirname(CONFIG_PATH), { recursive: true });
108
117
  }
118
+ /** Persist only user-editable Web fields while preserving advanced configuration. */
119
+ export function saveConfigPatch(patch) {
120
+ const existing = readConfigFile();
121
+ const { web: webPatch, ...flatPatch } = patch;
122
+ const next = {
123
+ ...existing,
124
+ ...flatPatch,
125
+ ...(webPatch ? { web: { ...DEFAULT_CONFIG.web, ...(existing.web ?? {}), ...webPatch } } : {}),
126
+ };
127
+ if (patch.provider !== undefined)
128
+ next.provider = normalizeDeepCccProvider(patch.provider);
129
+ if (patch.baseURL !== undefined && !patch.baseURL.trim())
130
+ throw new Error("baseURL must not be empty");
131
+ if (patch.model !== undefined && !patch.model.trim())
132
+ throw new Error("model must not be empty");
133
+ if (patch.effort !== undefined && !["", "none", "minimal", "low", "medium", "high", "xhigh", "max"].includes(patch.effort)) {
134
+ throw new Error(`Unsupported effort: ${patch.effort}`);
135
+ }
136
+ if (patch.contextWindow !== undefined && (!Number.isInteger(patch.contextWindow) || patch.contextWindow <= 0)) {
137
+ throw new Error("contextWindow must be a positive integer");
138
+ }
139
+ if (webPatch?.port !== undefined) {
140
+ next.web = { ...(next.web ?? DEFAULT_CONFIG.web), port: normalizeWebPort(webPatch.port) };
141
+ }
142
+ ensureConfigDir();
143
+ const tempPath = `${CONFIG_PATH}.${process.pid}.tmp`;
144
+ writeFileSync(tempPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
145
+ renameSync(tempPath, CONFIG_PATH);
146
+ return loadConfig();
147
+ }
148
+ function normalizeWebPort(value) {
149
+ const parsed = Number(value ?? DEFAULT_CONFIG.web.port);
150
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65_535)
151
+ return DEFAULT_CONFIG.web.port;
152
+ return parsed;
153
+ }
109
154
  export const config = loadConfig();