chatccc 0.2.270 → 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 (42) hide show
  1. package/README.md +16 -10
  2. package/config.sample.json +4 -3
  3. package/deepccc-agent/README.md +147 -61
  4. package/deepccc-agent/package.json +5 -2
  5. package/dist/deepccc-agent/src/attachments.js +192 -0
  6. package/dist/deepccc-agent/src/cli.js +59 -13
  7. package/dist/deepccc-agent/src/config.js +57 -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 +68 -21
  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 +5 -1
  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/application/task-execution-service.js +330 -97
  24. package/dist/src/agent-team/domain/task-run.js +14 -1
  25. package/dist/src/agent-team/infrastructure/task-execution-runtime.js +7 -2
  26. package/dist/src/agent-team/main-agent-bootstrap.js +24 -1
  27. package/dist/src/agent-team/repositories/json-task-run-repository.js +22 -4
  28. package/dist/src/agent-team/web/agent-team-page.js +14 -7
  29. package/dist/src/cards.js +7 -4
  30. package/dist/src/config.js +12 -0
  31. package/dist/src/im-skills.js +9 -2
  32. package/dist/src/orchestrator.js +117 -29
  33. package/dist/src/safe-maintenance.js +4 -1
  34. package/dist/src/session-name.js +15 -0
  35. package/dist/src/session.js +54 -9
  36. package/dist/src/web-ui.js +76 -32
  37. package/im-skills/feishu-skill/receive-send-file.md +3 -2
  38. package/im-skills/feishu-skill/receive-send-image.md +3 -2
  39. package/im-skills/feishu-skill/send-file.mjs +6 -5
  40. package/im-skills/feishu-skill/send-image.mjs +6 -5
  41. package/im-skills/feishu-skill/skill.md +4 -2
  42. package/package.json +1 -1
@@ -0,0 +1,476 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { createServer } from "node:http";
5
+ import { stat } from "node:fs/promises";
6
+ import { createRequire } from "node:module";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { MAX_ATTACHMENT_BYTES } from "./attachments.js";
10
+ import { DEEPCCC_HOME, loadConfig, saveConfigPatch, } from "./config.js";
11
+ import { killProcessTree } from "./proc-tree-kill.js";
12
+ import { DeepCccWebRuntime } from "./web-runtime.js";
13
+ import { DEEPCCC_WEB_PAGE } from "./web-page.js";
14
+ const MAX_BODY_BYTES = 512 * 1024;
15
+ const HOST = "127.0.0.1";
16
+ let defaultHandle = null;
17
+ export const DEEPCCC_WEB_STATE_FILE = join(DEEPCCC_HOME, "web", "server.json");
18
+ function runtimeConfig() {
19
+ const config = loadConfig();
20
+ return {
21
+ provider: config.provider,
22
+ apiKey: config.apiKey,
23
+ baseURL: config.baseURL,
24
+ model: config.model,
25
+ subModel: config.subModel,
26
+ effort: config.effort,
27
+ maxOutputTokens: config.maxOutputTokens,
28
+ contextWindow: config.contextWindow,
29
+ streaming: config.streaming,
30
+ };
31
+ }
32
+ export function toPublicConfig(config, defaultCwd = process.cwd()) {
33
+ return {
34
+ provider: config.provider,
35
+ baseURL: config.baseURL,
36
+ model: config.model,
37
+ subModel: config.subModel,
38
+ effort: config.effort,
39
+ ...(config.maxOutputTokens ? { maxOutputTokens: config.maxOutputTokens } : {}),
40
+ streaming: config.streaming,
41
+ contextWindow: config.contextWindow,
42
+ web: config.web,
43
+ apiKeyConfigured: !!config.apiKey,
44
+ apiKeyMask: maskSecret(config.apiKey),
45
+ defaultCwd,
46
+ };
47
+ }
48
+ export function createDeepCccWebRequestHandler(options) {
49
+ return async function handle(req, res) {
50
+ const url = new URL(req.url ?? "/", `http://${HOST}`);
51
+ const path = url.pathname;
52
+ const method = req.method ?? "GET";
53
+ try {
54
+ if (method === "GET" && (path === "/" || path === "/index.html")) {
55
+ return textReply(res, 200, DEEPCCC_WEB_PAGE, "text/html; charset=utf-8");
56
+ }
57
+ if (method === "GET" && path === "/api/health") {
58
+ return jsonReply(res, 200, {
59
+ ok: true,
60
+ service: "deepccc-web",
61
+ ...(options.instance ? {
62
+ pid: options.instance.pid,
63
+ port: options.instance.port,
64
+ startedAt: options.instance.startedAt,
65
+ instanceToken: options.instance.token,
66
+ } : {}),
67
+ });
68
+ }
69
+ if (method === "POST" && path === "/api/shutdown") {
70
+ if (!options.instance || req.headers["x-deepccc-instance-token"] !== options.instance.token) {
71
+ throw new WebHttpError(403, "DeepCCC Web shutdown token mismatch");
72
+ }
73
+ jsonReply(res, 202, { ok: true, shuttingDown: true });
74
+ setTimeout(options.instance.shutdown, 0).unref?.();
75
+ return;
76
+ }
77
+ if (method === "GET" && path === "/api/config") {
78
+ return jsonReply(res, 200, { ok: true, config: { ...options.getPublicConfig(), defaultCwd: options.defaultCwd } });
79
+ }
80
+ if (method === "PUT" && path === "/api/config") {
81
+ const body = await readJson(req);
82
+ const config = await options.saveConfig(body);
83
+ return jsonReply(res, 200, { ok: true, config: { ...config, defaultCwd: options.defaultCwd } });
84
+ }
85
+ if (method === "GET" && path === "/api/sessions") {
86
+ return jsonReply(res, 200, { ok: true, sessions: await options.runtime.listSessions() });
87
+ }
88
+ if (method === "GET" && path === "/api/events") {
89
+ return openGlobalEventStream(req, res, options.runtime);
90
+ }
91
+ if (method === "POST" && path === "/api/sessions") {
92
+ const body = await readJson(req);
93
+ const cwd = body.cwd?.trim() || options.defaultCwd;
94
+ await assertDirectory(cwd);
95
+ const session = await options.runtime.createSession({ ...body, cwd });
96
+ return jsonReply(res, 201, { ok: true, session });
97
+ }
98
+ const attachmentMatch = path.match(/^\/api\/sessions\/([^/]+)\/attachments(?:\/([^/]+))?$/);
99
+ if (attachmentMatch) {
100
+ const sessionId = decodeURIComponent(attachmentMatch[1]);
101
+ const attachmentId = attachmentMatch[2] ? decodeURIComponent(attachmentMatch[2]) : "";
102
+ if (method === "POST" && !attachmentId) {
103
+ const originalName = url.searchParams.get("name")?.trim() || "image";
104
+ const bytes = await readBinary(req, MAX_ATTACHMENT_BYTES);
105
+ const attachment = await options.runtime.addAttachment(sessionId, { originalName, bytes });
106
+ return jsonReply(res, 201, { ok: true, attachment });
107
+ }
108
+ if (method === "GET" && attachmentId) {
109
+ const stored = await options.runtime.readAttachment(sessionId, attachmentId);
110
+ if (!stored)
111
+ return jsonReply(res, 404, { ok: false, error: "Image attachment not found" });
112
+ return binaryReply(res, 200, stored.bytes, stored.attachment.mimeType, stored.attachment.originalName);
113
+ }
114
+ if (method === "DELETE" && attachmentId) {
115
+ const deleted = await options.runtime.deleteAttachment(sessionId, attachmentId);
116
+ return jsonReply(res, deleted ? 200 : 404, { ok: deleted });
117
+ }
118
+ }
119
+ const artifactMatch = path.match(/^\/api\/sessions\/([^/]+)\/artifact$/);
120
+ if (artifactMatch && method === "GET") {
121
+ const artifactPath = url.searchParams.get("path") ?? "";
122
+ if (!artifactPath)
123
+ throw new WebHttpError(400, "Artifact path is required");
124
+ const artifact = await options.runtime.readArtifact(decodeURIComponent(artifactMatch[1]), artifactPath);
125
+ if (!artifact)
126
+ return jsonReply(res, 404, { ok: false, error: "Artifact image not found" });
127
+ return binaryReply(res, 200, artifact.bytes, artifact.mimeType, artifact.path.split(/[\\/]/).at(-1) ?? "image");
128
+ }
129
+ const sessionMatch = path.match(/^\/api\/sessions\/([^/]+)$/);
130
+ if (sessionMatch && method === "GET") {
131
+ return jsonReply(res, 200, { ok: true, session: await options.runtime.getSession(decodeURIComponent(sessionMatch[1])) });
132
+ }
133
+ if (sessionMatch && method === "PATCH") {
134
+ const body = await readJson(req);
135
+ const session = await options.runtime.updateSession(decodeURIComponent(sessionMatch[1]), {
136
+ ...(typeof body.title === "string" ? { title: body.title } : {}),
137
+ ...(typeof body.model === "string" ? { model: body.model } : {}),
138
+ ...(typeof body.subModel === "string" ? { subModel: body.subModel } : {}),
139
+ ...(typeof body.effort === "string" ? { effort: body.effort } : {}),
140
+ });
141
+ return jsonReply(res, 200, { ok: true, session });
142
+ }
143
+ if (sessionMatch && method === "DELETE") {
144
+ const deleted = await options.runtime.deleteSession(decodeURIComponent(sessionMatch[1]));
145
+ return jsonReply(res, deleted ? 200 : 404, { ok: deleted });
146
+ }
147
+ const messagesMatch = path.match(/^\/api\/sessions\/([^/]+)\/messages$/);
148
+ if (messagesMatch && method === "POST") {
149
+ const body = await readJson(req);
150
+ const attachmentIds = Array.isArray(body.attachmentIds)
151
+ ? body.attachmentIds.filter((value) => typeof value === "string")
152
+ : [];
153
+ const result = await options.runtime.sendMessage(decodeURIComponent(messagesMatch[1]), body.text ?? "", attachmentIds);
154
+ return jsonReply(res, 202, { ok: true, ...result });
155
+ }
156
+ const stopMatch = path.match(/^\/api\/sessions\/([^/]+)\/stop$/);
157
+ if (stopMatch && method === "POST") {
158
+ return jsonReply(res, 200, { ok: true, stopped: await options.runtime.stopSession(decodeURIComponent(stopMatch[1])) });
159
+ }
160
+ const eventsMatch = path.match(/^\/api\/sessions\/([^/]+)\/events$/);
161
+ if (eventsMatch && method === "GET") {
162
+ return openEventStream(req, res, decodeURIComponent(eventsMatch[1]), options.runtime);
163
+ }
164
+ const approvalMatch = path.match(/^\/api\/approvals\/([^/]+)$/);
165
+ if (approvalMatch && method === "POST") {
166
+ const body = await readJson(req);
167
+ if (!body.answer || !["allow", "allow-session", "allow-always", "deny"].includes(body.answer)) {
168
+ throw new WebHttpError(400, "Invalid approval answer");
169
+ }
170
+ const resolved = await options.runtime.resolveApproval(decodeURIComponent(approvalMatch[1]), body.answer);
171
+ return jsonReply(res, resolved ? 200 : 404, { ok: resolved });
172
+ }
173
+ if (path.startsWith("/api/"))
174
+ return jsonReply(res, 404, { ok: false, error: "Not found" });
175
+ if (method === "GET")
176
+ return textReply(res, 200, DEEPCCC_WEB_PAGE, "text/html; charset=utf-8");
177
+ return jsonReply(res, 404, { ok: false, error: "Not found" });
178
+ }
179
+ catch (err) {
180
+ const status = err instanceof WebHttpError ? err.status : 500;
181
+ jsonReply(res, status, { ok: false, error: err instanceof Error ? err.message : String(err) });
182
+ }
183
+ };
184
+ }
185
+ export async function startDeepCccWebServer(options = {}) {
186
+ const config = loadConfig();
187
+ const port = options.port ?? config.web.port;
188
+ const url = `http://${HOST}:${port}/`;
189
+ if (defaultHandle?.port === port) {
190
+ if (options.reuseExisting) {
191
+ if (options.openBrowser !== false)
192
+ openBrowser(url);
193
+ return defaultHandle;
194
+ }
195
+ await defaultHandle.close();
196
+ }
197
+ const stateFile = options.stateFile ?? DEEPCCC_WEB_STATE_FILE;
198
+ const existing = await inspectDeepCccWebServer(port);
199
+ if (existing && options.reuseExisting) {
200
+ const reused = { url, port, reused: true, close: async () => { } };
201
+ if (options.openBrowser !== false)
202
+ openBrowser(url);
203
+ return reused;
204
+ }
205
+ if (existing)
206
+ await stopOwnedDeepCccWebServer(existing, stateFile);
207
+ const runtime = new DeepCccWebRuntime({ loadConfig: runtimeConfig });
208
+ const defaultCwd = options.defaultCwd ?? process.cwd();
209
+ const instance = {
210
+ pid: process.pid,
211
+ port,
212
+ startedAt: new Date().toISOString(),
213
+ token: randomUUID(),
214
+ };
215
+ let closeServer = () => { };
216
+ const handler = createDeepCccWebRequestHandler({
217
+ runtime,
218
+ defaultCwd,
219
+ getPublicConfig: () => toPublicConfig(loadConfig(), defaultCwd),
220
+ saveConfig: (patch) => toPublicConfig(saveConfigPatch(patch), defaultCwd),
221
+ instance: { ...instance, shutdown: () => closeServer() },
222
+ });
223
+ const server = createServer((req, res) => { void handler(req, res); });
224
+ await new Promise((resolve, reject) => {
225
+ const onError = (err) => reject(err);
226
+ server.once("error", onError);
227
+ server.listen(port, HOST, () => {
228
+ server.off("error", onError);
229
+ resolve();
230
+ });
231
+ });
232
+ const handle = {
233
+ url,
234
+ port,
235
+ reused: false,
236
+ close: () => new Promise((resolve, reject) => {
237
+ server.close((err) => {
238
+ if (err)
239
+ return reject(err);
240
+ removeOwnedState(stateFile, instance.token);
241
+ if (defaultHandle?.port === port)
242
+ defaultHandle = null;
243
+ resolve();
244
+ });
245
+ // SSE and keep-alive sockets would otherwise keep a replaced standalone
246
+ // process alive after it has released the listening port.
247
+ server.closeAllConnections?.();
248
+ }),
249
+ };
250
+ closeServer = () => { void handle.close(); };
251
+ defaultHandle = handle;
252
+ writeOwnedState(stateFile, instance);
253
+ if (options.openBrowser ?? config.web.openOnStart)
254
+ openBrowser(url);
255
+ return handle;
256
+ }
257
+ export async function launchDeepCccWebProcess(options = {}) {
258
+ const config = loadConfig();
259
+ const port = options.port ?? config.web.port;
260
+ const url = `http://${HOST}:${port}/`;
261
+ if (options.reuseExisting && await inspectDeepCccWebServer(port)) {
262
+ if (options.openBrowser !== false)
263
+ openBrowser(url);
264
+ return { url, port, reused: true };
265
+ }
266
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
267
+ const compiledEntry = join(moduleDir, "web-entry.js");
268
+ const args = [
269
+ ...(options.reuseExisting ? ["--reuse-existing"] : []),
270
+ "--port", String(port),
271
+ ...(options.openBrowser === false ? ["--no-open"] : []),
272
+ ];
273
+ const require = createRequire(import.meta.url);
274
+ const commandArgs = existsSync(compiledEntry)
275
+ ? [compiledEntry, ...args]
276
+ : [require.resolve("tsx/cli"), join(moduleDir, "web-entry.ts"), ...args];
277
+ const child = spawn(process.execPath, commandArgs, {
278
+ cwd: options.defaultCwd ?? process.cwd(),
279
+ detached: true,
280
+ stdio: "ignore",
281
+ windowsHide: true,
282
+ });
283
+ child.unref();
284
+ const deadline = Date.now() + 8_000;
285
+ while (Date.now() < deadline) {
286
+ if (await inspectDeepCccWebServer(port))
287
+ return { url, port, reused: false };
288
+ await new Promise((resolve) => setTimeout(resolve, 100));
289
+ }
290
+ throw new Error(`DeepCCC Web did not become ready on port ${port}`);
291
+ }
292
+ function openEventStream(req, res, sessionId, runtime) {
293
+ res.writeHead(200, {
294
+ "content-type": "text/event-stream; charset=utf-8",
295
+ "cache-control": "no-cache, no-transform",
296
+ connection: "keep-alive",
297
+ "x-accel-buffering": "no",
298
+ });
299
+ res.write(": connected\n\n");
300
+ const send = (event) => res.write(`id: ${event.eventId}\ndata: ${JSON.stringify(event)}\n\n`);
301
+ const unsubscribe = runtime.subscribe(sessionId, send);
302
+ const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), 15_000);
303
+ heartbeat.unref?.();
304
+ req.on("close", () => { clearInterval(heartbeat); unsubscribe(); });
305
+ }
306
+ function openGlobalEventStream(req, res, runtime) {
307
+ res.writeHead(200, {
308
+ "content-type": "text/event-stream; charset=utf-8",
309
+ "cache-control": "no-cache, no-transform",
310
+ connection: "keep-alive",
311
+ "x-accel-buffering": "no",
312
+ });
313
+ res.write(": connected\n\n");
314
+ const send = (event) => res.write(`id: ${event.eventId}\ndata: ${JSON.stringify(event)}\n\n`);
315
+ const unsubscribe = runtime.subscribeAll(send);
316
+ const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), 15_000);
317
+ heartbeat.unref?.();
318
+ req.on("close", () => { clearInterval(heartbeat); unsubscribe(); });
319
+ }
320
+ async function readJson(req) {
321
+ const chunks = [];
322
+ let bytes = 0;
323
+ for await (const chunk of req) {
324
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
325
+ bytes += buffer.length;
326
+ if (bytes > MAX_BODY_BYTES)
327
+ throw new WebHttpError(413, "Request body is too large");
328
+ chunks.push(buffer);
329
+ }
330
+ if (!chunks.length)
331
+ return {};
332
+ try {
333
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
334
+ }
335
+ catch {
336
+ throw new WebHttpError(400, "Invalid JSON body");
337
+ }
338
+ }
339
+ async function readBinary(req, maxBytes) {
340
+ const chunks = [];
341
+ let bytes = 0;
342
+ for await (const chunk of req) {
343
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
344
+ bytes += buffer.length;
345
+ if (bytes > maxBytes)
346
+ throw new WebHttpError(413, "Image attachment exceeds the 20 MB limit");
347
+ chunks.push(buffer);
348
+ }
349
+ if (!chunks.length)
350
+ throw new WebHttpError(400, "Image attachment must not be empty");
351
+ return Buffer.concat(chunks);
352
+ }
353
+ async function assertDirectory(path) {
354
+ try {
355
+ if (!(await stat(path)).isDirectory())
356
+ throw new Error("not a directory");
357
+ }
358
+ catch {
359
+ throw new WebHttpError(400, `工作目录不存在:${path}`);
360
+ }
361
+ }
362
+ function maskSecret(value) {
363
+ if (!value)
364
+ return "";
365
+ const suffix = value.slice(-4);
366
+ return `${value.slice(0, Math.min(3, Math.max(0, value.length - 4)))}••••${suffix}`;
367
+ }
368
+ function jsonReply(res, status, value) {
369
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
370
+ res.end(JSON.stringify(value));
371
+ }
372
+ function textReply(res, status, value, contentType) {
373
+ res.writeHead(status, { "content-type": contentType, "cache-control": "no-store" });
374
+ res.end(value);
375
+ }
376
+ function binaryReply(res, status, value, contentType, fileName) {
377
+ res.writeHead(status, {
378
+ "content-type": contentType,
379
+ "content-length": String(value.byteLength),
380
+ "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(fileName)}`,
381
+ "cache-control": "no-store",
382
+ "x-content-type-options": "nosniff",
383
+ });
384
+ res.end(value);
385
+ }
386
+ class WebHttpError extends Error {
387
+ status;
388
+ constructor(status, message) {
389
+ super(message);
390
+ this.status = status;
391
+ }
392
+ }
393
+ export async function inspectDeepCccWebServer(port) {
394
+ try {
395
+ const response = await fetch(`http://${HOST}:${port}/api/health`, { signal: AbortSignal.timeout(800) });
396
+ if (!response.ok)
397
+ return null;
398
+ const health = await response.json();
399
+ if (health.service !== "deepccc-web")
400
+ return null;
401
+ return {
402
+ service: "deepccc-web",
403
+ pid: typeof health.pid === "number" ? health.pid : 0,
404
+ port: typeof health.port === "number" ? health.port : port,
405
+ startedAt: typeof health.startedAt === "string" ? health.startedAt : "",
406
+ token: typeof health.instanceToken === "string" ? health.instanceToken : "",
407
+ };
408
+ }
409
+ catch {
410
+ return null;
411
+ }
412
+ }
413
+ async function stopOwnedDeepCccWebServer(existing, stateFile) {
414
+ const owned = readOwnedState(stateFile);
415
+ if (!owned || !sameInstance(owned, existing)) {
416
+ throw new Error(`Port ${existing.port} is occupied by an unverified DeepCCC Web instance; use --reuse-existing or stop it manually`);
417
+ }
418
+ try {
419
+ await fetch(`http://${HOST}:${existing.port}/api/shutdown`, {
420
+ method: "POST",
421
+ headers: { "x-deepccc-instance-token": existing.token },
422
+ signal: AbortSignal.timeout(1_500),
423
+ });
424
+ }
425
+ catch { /* fall through to verified force-kill */ }
426
+ const deadline = Date.now() + 3_000;
427
+ while (Date.now() < deadline) {
428
+ if (!await inspectDeepCccWebServer(existing.port))
429
+ return;
430
+ await new Promise((resolve) => setTimeout(resolve, 100));
431
+ }
432
+ const current = await inspectDeepCccWebServer(existing.port);
433
+ if (current && sameInstance(owned, current))
434
+ await killProcessTree(owned.pid);
435
+ const finalDeadline = Date.now() + 3_000;
436
+ while (Date.now() < finalDeadline) {
437
+ if (!await inspectDeepCccWebServer(existing.port))
438
+ return;
439
+ await new Promise((resolve) => setTimeout(resolve, 100));
440
+ }
441
+ throw new Error(`Verified DeepCCC Web process ${owned.pid} did not release port ${existing.port}`);
442
+ }
443
+ function sameInstance(left, right) {
444
+ return left.pid > 0 && left.pid === right.pid && left.port === right.port && !!left.token && left.token === right.token && left.startedAt === right.startedAt;
445
+ }
446
+ function readOwnedState(path) {
447
+ try {
448
+ const value = JSON.parse(readFileSync(path, "utf8"));
449
+ if (typeof value.pid !== "number" || typeof value.port !== "number" || typeof value.startedAt !== "string" || typeof value.token !== "string")
450
+ return null;
451
+ return value;
452
+ }
453
+ catch {
454
+ return null;
455
+ }
456
+ }
457
+ function writeOwnedState(path, instance) {
458
+ mkdirSync(dirname(path), { recursive: true });
459
+ const temp = `${path}.${process.pid}.tmp`;
460
+ writeFileSync(temp, `${JSON.stringify(instance, null, 2)}\n`, "utf8");
461
+ renameSync(temp, path);
462
+ }
463
+ function removeOwnedState(path, token) {
464
+ if (readOwnedState(path)?.token !== token)
465
+ return;
466
+ try {
467
+ unlinkSync(path);
468
+ }
469
+ catch { /* already removed */ }
470
+ }
471
+ function openBrowser(url) {
472
+ const command = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
473
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
474
+ const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
475
+ child.unref();
476
+ }
@@ -0,0 +1,162 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { DEFAULT_BUILTIN_CONTEXT_DIR, getBuiltinContextSession, normalizeBuiltinSessionId, } from "./context.js";
5
+ export class WebSessionStore {
6
+ rootDir;
7
+ now;
8
+ idFactory;
9
+ constructor(options = {}) {
10
+ this.rootDir = options.rootDir ?? DEFAULT_BUILTIN_CONTEXT_DIR;
11
+ this.now = options.now ?? (() => new Date());
12
+ this.idFactory = options.idFactory ?? (() => `web-${randomUUID()}`);
13
+ }
14
+ async create(input) {
15
+ const cwd = cleanCwd(input.cwd);
16
+ const timestamp = this.now().toISOString();
17
+ const sessionId = normalizeBuiltinSessionId(this.idFactory());
18
+ const meta = {
19
+ schemaVersion: 1,
20
+ sessionId,
21
+ title: cleanTitle(input.title) || basename(cwd) || "新会话",
22
+ cwd,
23
+ model: input.model?.trim() ?? "",
24
+ subModel: input.subModel?.trim() ?? "",
25
+ effort: input.effort?.trim() ?? "",
26
+ createdAt: timestamp,
27
+ updatedAt: timestamp,
28
+ approvals: [],
29
+ };
30
+ await this.save(meta);
31
+ return meta;
32
+ }
33
+ async get(sessionId) {
34
+ const normalized = normalizeBuiltinSessionId(sessionId);
35
+ try {
36
+ return parseMeta(JSON.parse(await readFile(this.metaPath(normalized), "utf8")));
37
+ }
38
+ catch (err) {
39
+ if (err.code === "ENOENT") {
40
+ const context = getBuiltinContextSession(normalized, this.rootDir);
41
+ if (!context)
42
+ return null;
43
+ const cwd = context.cwd ?? process.cwd();
44
+ return {
45
+ schemaVersion: 1,
46
+ sessionId: normalized,
47
+ title: basename(cwd) || normalized,
48
+ cwd,
49
+ model: "",
50
+ subModel: "",
51
+ effort: "",
52
+ createdAt: new Date(context.createdAt).toISOString(),
53
+ updatedAt: new Date(context.updatedAt).toISOString(),
54
+ approvals: [],
55
+ };
56
+ }
57
+ return null;
58
+ }
59
+ }
60
+ async list() {
61
+ let entries;
62
+ try {
63
+ entries = await readdir(this.rootDir);
64
+ }
65
+ catch (err) {
66
+ if (err.code === "ENOENT")
67
+ return [];
68
+ throw err;
69
+ }
70
+ const sessions = await Promise.all(entries.map((entry) => this.get(entry)));
71
+ return sessions
72
+ .filter((session) => session !== null)
73
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.sessionId.localeCompare(b.sessionId));
74
+ }
75
+ async update(sessionId, patch) {
76
+ const current = await this.require(sessionId);
77
+ const updated = {
78
+ ...current,
79
+ ...(patch.title !== undefined ? { title: cleanTitle(patch.title) || current.title } : {}),
80
+ ...(patch.model !== undefined ? { model: patch.model.trim() } : {}),
81
+ ...(patch.subModel !== undefined ? { subModel: patch.subModel.trim() } : {}),
82
+ ...(patch.effort !== undefined ? { effort: patch.effort.trim() } : {}),
83
+ updatedAt: this.now().toISOString(),
84
+ };
85
+ await this.save(updated);
86
+ return updated;
87
+ }
88
+ async delete(sessionId) {
89
+ const normalized = normalizeBuiltinSessionId(sessionId);
90
+ if (!await this.get(normalized))
91
+ return false;
92
+ await rm(join(this.rootDir, normalized), { recursive: true, force: true });
93
+ return true;
94
+ }
95
+ async addApproval(sessionId, approval) {
96
+ const meta = await this.require(sessionId);
97
+ await this.save({ ...meta, approvals: [...meta.approvals, approval].slice(-200), updatedAt: this.now().toISOString() });
98
+ }
99
+ async resolveApproval(sessionId, approvalId, answer) {
100
+ const meta = await this.require(sessionId);
101
+ const resolvedAt = this.now().toISOString();
102
+ await this.save({
103
+ ...meta,
104
+ updatedAt: resolvedAt,
105
+ approvals: meta.approvals.map((approval) => approval.approvalId === approvalId
106
+ ? { ...approval, status: "resolved", answer, resolvedAt }
107
+ : approval),
108
+ });
109
+ }
110
+ async require(sessionId) {
111
+ const meta = await this.get(sessionId);
112
+ if (!meta)
113
+ throw new Error(`DeepCCC web session not found: ${sessionId}`);
114
+ return meta;
115
+ }
116
+ async save(meta) {
117
+ const path = this.metaPath(meta.sessionId);
118
+ await mkdir(join(this.rootDir, meta.sessionId), { recursive: true });
119
+ const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
120
+ await writeFile(tempPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
121
+ await rename(tempPath, path);
122
+ }
123
+ metaPath(sessionId) {
124
+ const normalized = normalizeBuiltinSessionId(sessionId);
125
+ return join(this.rootDir, normalized, "web.json");
126
+ }
127
+ }
128
+ function cleanCwd(value) {
129
+ if (typeof value !== "string" || !value.trim())
130
+ throw new Error("cwd must be a non-empty path");
131
+ return resolve(value.trim());
132
+ }
133
+ function cleanTitle(value) {
134
+ return value?.trim().slice(0, 120) ?? "";
135
+ }
136
+ function parseMeta(value) {
137
+ if (!value || typeof value !== "object" || Array.isArray(value))
138
+ throw new Error("Invalid web session metadata");
139
+ const meta = value;
140
+ if (meta.schemaVersion !== 1)
141
+ throw new Error("Unsupported web session metadata");
142
+ for (const field of ["sessionId", "title", "cwd", "model", "subModel", "effort", "createdAt", "updatedAt"]) {
143
+ if (typeof meta[field] !== "string")
144
+ throw new Error(`Invalid web session ${field}`);
145
+ }
146
+ const approvals = Array.isArray(meta.approvals)
147
+ ? meta.approvals.filter(isApprovalRecord)
148
+ : [];
149
+ return { ...meta, approvals };
150
+ }
151
+ function isApprovalRecord(value) {
152
+ if (!value || typeof value !== "object" || Array.isArray(value))
153
+ return false;
154
+ const approval = value;
155
+ return typeof approval.approvalId === "string"
156
+ && typeof approval.tool === "string"
157
+ && typeof approval.action === "string"
158
+ && typeof approval.reason === "string"
159
+ && typeof approval.detail === "string"
160
+ && typeof approval.createdAt === "string"
161
+ && (approval.status === "pending" || approval.status === "resolved");
162
+ }