deepccc 0.1.27 → 0.2.0

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.
@@ -0,0 +1,243 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createServer } from "node:http";
3
+ import { stat } from "node:fs/promises";
4
+ import { loadConfig, saveConfigPatch, } from "./config.js";
5
+ import { DeepCccWebRuntime } from "./web-runtime.js";
6
+ import { DEEPCCC_WEB_PAGE } from "./web-page.js";
7
+ const MAX_BODY_BYTES = 512 * 1024;
8
+ const HOST = "127.0.0.1";
9
+ let defaultHandle = null;
10
+ function runtimeConfig() {
11
+ const config = loadConfig();
12
+ return {
13
+ provider: config.provider,
14
+ apiKey: config.apiKey,
15
+ baseURL: config.baseURL,
16
+ model: config.model,
17
+ subModel: config.subModel,
18
+ effort: config.effort,
19
+ maxOutputTokens: config.maxOutputTokens,
20
+ contextWindow: config.contextWindow,
21
+ streaming: config.streaming,
22
+ };
23
+ }
24
+ export function toPublicConfig(config, defaultCwd = process.cwd()) {
25
+ return {
26
+ provider: config.provider,
27
+ baseURL: config.baseURL,
28
+ model: config.model,
29
+ subModel: config.subModel,
30
+ effort: config.effort,
31
+ ...(config.maxOutputTokens ? { maxOutputTokens: config.maxOutputTokens } : {}),
32
+ streaming: config.streaming,
33
+ contextWindow: config.contextWindow,
34
+ web: config.web,
35
+ apiKeyConfigured: !!config.apiKey,
36
+ apiKeyMask: maskSecret(config.apiKey),
37
+ defaultCwd,
38
+ };
39
+ }
40
+ export function createDeepCccWebRequestHandler(options) {
41
+ return async function handle(req, res) {
42
+ const url = new URL(req.url ?? "/", `http://${HOST}`);
43
+ const path = url.pathname;
44
+ const method = req.method ?? "GET";
45
+ try {
46
+ if (method === "GET" && (path === "/" || path === "/index.html")) {
47
+ return textReply(res, 200, DEEPCCC_WEB_PAGE, "text/html; charset=utf-8");
48
+ }
49
+ if (method === "GET" && path === "/api/health") {
50
+ return jsonReply(res, 200, { ok: true, service: "deepccc-web" });
51
+ }
52
+ if (method === "GET" && path === "/api/config") {
53
+ return jsonReply(res, 200, { ok: true, config: { ...options.getPublicConfig(), defaultCwd: options.defaultCwd } });
54
+ }
55
+ if (method === "PUT" && path === "/api/config") {
56
+ const body = await readJson(req);
57
+ const config = await options.saveConfig(body);
58
+ return jsonReply(res, 200, { ok: true, config: { ...config, defaultCwd: options.defaultCwd } });
59
+ }
60
+ if (method === "GET" && path === "/api/sessions") {
61
+ return jsonReply(res, 200, { ok: true, sessions: await options.runtime.listSessions() });
62
+ }
63
+ if (method === "POST" && path === "/api/sessions") {
64
+ const body = await readJson(req);
65
+ const cwd = body.cwd?.trim() || options.defaultCwd;
66
+ await assertDirectory(cwd);
67
+ const session = await options.runtime.createSession({ ...body, cwd });
68
+ return jsonReply(res, 201, { ok: true, session });
69
+ }
70
+ const sessionMatch = path.match(/^\/api\/sessions\/([^/]+)$/);
71
+ if (sessionMatch && method === "GET") {
72
+ return jsonReply(res, 200, { ok: true, session: await options.runtime.getSession(decodeURIComponent(sessionMatch[1])) });
73
+ }
74
+ if (sessionMatch && method === "PATCH") {
75
+ const body = await readJson(req);
76
+ const session = await options.runtime.updateSession(decodeURIComponent(sessionMatch[1]), {
77
+ ...(typeof body.title === "string" ? { title: body.title } : {}),
78
+ ...(typeof body.model === "string" ? { model: body.model } : {}),
79
+ ...(typeof body.subModel === "string" ? { subModel: body.subModel } : {}),
80
+ ...(typeof body.effort === "string" ? { effort: body.effort } : {}),
81
+ });
82
+ return jsonReply(res, 200, { ok: true, session });
83
+ }
84
+ if (sessionMatch && method === "DELETE") {
85
+ const deleted = await options.runtime.deleteSession(decodeURIComponent(sessionMatch[1]));
86
+ return jsonReply(res, deleted ? 200 : 404, { ok: deleted });
87
+ }
88
+ const messagesMatch = path.match(/^\/api\/sessions\/([^/]+)\/messages$/);
89
+ if (messagesMatch && method === "POST") {
90
+ const body = await readJson(req);
91
+ const result = await options.runtime.sendMessage(decodeURIComponent(messagesMatch[1]), body.text ?? "");
92
+ return jsonReply(res, 202, { ok: true, ...result });
93
+ }
94
+ const stopMatch = path.match(/^\/api\/sessions\/([^/]+)\/stop$/);
95
+ if (stopMatch && method === "POST") {
96
+ return jsonReply(res, 200, { ok: true, stopped: await options.runtime.stopSession(decodeURIComponent(stopMatch[1])) });
97
+ }
98
+ const eventsMatch = path.match(/^\/api\/sessions\/([^/]+)\/events$/);
99
+ if (eventsMatch && method === "GET") {
100
+ return openEventStream(req, res, decodeURIComponent(eventsMatch[1]), options.runtime);
101
+ }
102
+ const approvalMatch = path.match(/^\/api\/approvals\/([^/]+)$/);
103
+ if (approvalMatch && method === "POST") {
104
+ const body = await readJson(req);
105
+ if (!body.answer || !["allow", "allow-session", "allow-always", "deny"].includes(body.answer)) {
106
+ throw new WebHttpError(400, "Invalid approval answer");
107
+ }
108
+ const resolved = await options.runtime.resolveApproval(decodeURIComponent(approvalMatch[1]), body.answer);
109
+ return jsonReply(res, resolved ? 200 : 404, { ok: resolved });
110
+ }
111
+ if (path.startsWith("/api/"))
112
+ return jsonReply(res, 404, { ok: false, error: "Not found" });
113
+ if (method === "GET")
114
+ return textReply(res, 200, DEEPCCC_WEB_PAGE, "text/html; charset=utf-8");
115
+ return jsonReply(res, 404, { ok: false, error: "Not found" });
116
+ }
117
+ catch (err) {
118
+ const status = err instanceof WebHttpError ? err.status : 500;
119
+ jsonReply(res, status, { ok: false, error: err instanceof Error ? err.message : String(err) });
120
+ }
121
+ };
122
+ }
123
+ export async function startDeepCccWebServer(options = {}) {
124
+ const config = loadConfig();
125
+ const port = options.port ?? config.web.port;
126
+ const url = `http://${HOST}:${port}/`;
127
+ if (defaultHandle?.port === port) {
128
+ if (options.openBrowser !== false)
129
+ openBrowser(url);
130
+ return defaultHandle;
131
+ }
132
+ if (await isExistingDeepCccServer(port)) {
133
+ const reused = { url, port, reused: true, close: async () => { } };
134
+ if (options.openBrowser !== false)
135
+ openBrowser(url);
136
+ return reused;
137
+ }
138
+ const runtime = new DeepCccWebRuntime({ loadConfig: runtimeConfig });
139
+ const defaultCwd = options.defaultCwd ?? process.cwd();
140
+ const handler = createDeepCccWebRequestHandler({
141
+ runtime,
142
+ defaultCwd,
143
+ getPublicConfig: () => toPublicConfig(loadConfig(), defaultCwd),
144
+ saveConfig: (patch) => toPublicConfig(saveConfigPatch(patch), defaultCwd),
145
+ });
146
+ const server = createServer((req, res) => { void handler(req, res); });
147
+ await new Promise((resolve, reject) => {
148
+ const onError = (err) => reject(err);
149
+ server.once("error", onError);
150
+ server.listen(port, HOST, () => {
151
+ server.off("error", onError);
152
+ resolve();
153
+ });
154
+ });
155
+ const handle = {
156
+ url,
157
+ port,
158
+ reused: false,
159
+ close: () => new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve())),
160
+ };
161
+ defaultHandle = handle;
162
+ if (options.openBrowser ?? config.web.openOnStart)
163
+ openBrowser(url);
164
+ return handle;
165
+ }
166
+ function openEventStream(req, res, sessionId, runtime) {
167
+ res.writeHead(200, {
168
+ "content-type": "text/event-stream; charset=utf-8",
169
+ "cache-control": "no-cache, no-transform",
170
+ connection: "keep-alive",
171
+ "x-accel-buffering": "no",
172
+ });
173
+ res.write(": connected\n\n");
174
+ const send = (event) => res.write(`id: ${event.eventId}\ndata: ${JSON.stringify(event)}\n\n`);
175
+ const unsubscribe = runtime.subscribe(sessionId, send);
176
+ const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), 15_000);
177
+ heartbeat.unref?.();
178
+ req.on("close", () => { clearInterval(heartbeat); unsubscribe(); });
179
+ }
180
+ async function readJson(req) {
181
+ const chunks = [];
182
+ let bytes = 0;
183
+ for await (const chunk of req) {
184
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
185
+ bytes += buffer.length;
186
+ if (bytes > MAX_BODY_BYTES)
187
+ throw new WebHttpError(413, "Request body is too large");
188
+ chunks.push(buffer);
189
+ }
190
+ if (!chunks.length)
191
+ return {};
192
+ try {
193
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
194
+ }
195
+ catch {
196
+ throw new WebHttpError(400, "Invalid JSON body");
197
+ }
198
+ }
199
+ async function assertDirectory(path) {
200
+ try {
201
+ if (!(await stat(path)).isDirectory())
202
+ throw new Error("not a directory");
203
+ }
204
+ catch {
205
+ throw new WebHttpError(400, `工作目录不存在:${path}`);
206
+ }
207
+ }
208
+ function maskSecret(value) {
209
+ if (!value)
210
+ return "";
211
+ const suffix = value.slice(-4);
212
+ return `${value.slice(0, Math.min(3, Math.max(0, value.length - 4)))}••••${suffix}`;
213
+ }
214
+ function jsonReply(res, status, value) {
215
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
216
+ res.end(JSON.stringify(value));
217
+ }
218
+ function textReply(res, status, value, contentType) {
219
+ res.writeHead(status, { "content-type": contentType, "cache-control": "no-store" });
220
+ res.end(value);
221
+ }
222
+ class WebHttpError extends Error {
223
+ status;
224
+ constructor(status, message) {
225
+ super(message);
226
+ this.status = status;
227
+ }
228
+ }
229
+ async function isExistingDeepCccServer(port) {
230
+ try {
231
+ const response = await fetch(`http://${HOST}:${port}/api/health`, { signal: AbortSignal.timeout(800) });
232
+ return response.ok && (await response.json()).service === "deepccc-web";
233
+ }
234
+ catch {
235
+ return false;
236
+ }
237
+ }
238
+ function openBrowser(url) {
239
+ const command = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
240
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
241
+ const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
242
+ child.unref();
243
+ }
@@ -0,0 +1,43 @@
1
+ export interface WebSessionMeta {
2
+ schemaVersion: 1;
3
+ sessionId: string;
4
+ title: string;
5
+ cwd: string;
6
+ model: string;
7
+ subModel: string;
8
+ effort: string;
9
+ createdAt: string;
10
+ updatedAt: string;
11
+ }
12
+ export interface CreateWebSessionInput {
13
+ cwd: string;
14
+ title?: string;
15
+ model?: string;
16
+ subModel?: string;
17
+ effort?: string;
18
+ }
19
+ export interface UpdateWebSessionInput {
20
+ title?: string;
21
+ model?: string;
22
+ subModel?: string;
23
+ effort?: string;
24
+ }
25
+ export interface WebSessionStoreOptions {
26
+ rootDir?: string;
27
+ now?: () => Date;
28
+ idFactory?: () => string;
29
+ }
30
+ export declare class WebSessionStore {
31
+ readonly rootDir: string;
32
+ private readonly now;
33
+ private readonly idFactory;
34
+ constructor(options?: WebSessionStoreOptions);
35
+ create(input: CreateWebSessionInput): Promise<WebSessionMeta>;
36
+ get(sessionId: string): Promise<WebSessionMeta | null>;
37
+ list(): Promise<WebSessionMeta[]>;
38
+ update(sessionId: string, patch: UpdateWebSessionInput): Promise<WebSessionMeta>;
39
+ delete(sessionId: string): Promise<boolean>;
40
+ private require;
41
+ private save;
42
+ private metaPath;
43
+ }
@@ -0,0 +1,130 @@
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
+ };
29
+ await this.save(meta);
30
+ return meta;
31
+ }
32
+ async get(sessionId) {
33
+ const normalized = normalizeBuiltinSessionId(sessionId);
34
+ try {
35
+ return parseMeta(JSON.parse(await readFile(this.metaPath(normalized), "utf8")));
36
+ }
37
+ catch (err) {
38
+ if (err.code === "ENOENT") {
39
+ const context = getBuiltinContextSession(normalized, this.rootDir);
40
+ if (!context)
41
+ return null;
42
+ const cwd = context.cwd ?? process.cwd();
43
+ return {
44
+ schemaVersion: 1,
45
+ sessionId: normalized,
46
+ title: basename(cwd) || normalized,
47
+ cwd,
48
+ model: "",
49
+ subModel: "",
50
+ effort: "",
51
+ createdAt: new Date(context.createdAt).toISOString(),
52
+ updatedAt: new Date(context.updatedAt).toISOString(),
53
+ };
54
+ }
55
+ return null;
56
+ }
57
+ }
58
+ async list() {
59
+ let entries;
60
+ try {
61
+ entries = await readdir(this.rootDir);
62
+ }
63
+ catch (err) {
64
+ if (err.code === "ENOENT")
65
+ return [];
66
+ throw err;
67
+ }
68
+ const sessions = await Promise.all(entries.map((entry) => this.get(entry)));
69
+ return sessions
70
+ .filter((session) => session !== null)
71
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.sessionId.localeCompare(b.sessionId));
72
+ }
73
+ async update(sessionId, patch) {
74
+ const current = await this.require(sessionId);
75
+ const updated = {
76
+ ...current,
77
+ ...(patch.title !== undefined ? { title: cleanTitle(patch.title) || current.title } : {}),
78
+ ...(patch.model !== undefined ? { model: patch.model.trim() } : {}),
79
+ ...(patch.subModel !== undefined ? { subModel: patch.subModel.trim() } : {}),
80
+ ...(patch.effort !== undefined ? { effort: patch.effort.trim() } : {}),
81
+ updatedAt: this.now().toISOString(),
82
+ };
83
+ await this.save(updated);
84
+ return updated;
85
+ }
86
+ async delete(sessionId) {
87
+ const normalized = normalizeBuiltinSessionId(sessionId);
88
+ if (!await this.get(normalized))
89
+ return false;
90
+ await rm(join(this.rootDir, normalized), { recursive: true, force: true });
91
+ return true;
92
+ }
93
+ async require(sessionId) {
94
+ const meta = await this.get(sessionId);
95
+ if (!meta)
96
+ throw new Error(`DeepCCC web session not found: ${sessionId}`);
97
+ return meta;
98
+ }
99
+ async save(meta) {
100
+ const path = this.metaPath(meta.sessionId);
101
+ await mkdir(join(this.rootDir, meta.sessionId), { recursive: true });
102
+ const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
103
+ await writeFile(tempPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
104
+ await rename(tempPath, path);
105
+ }
106
+ metaPath(sessionId) {
107
+ const normalized = normalizeBuiltinSessionId(sessionId);
108
+ return join(this.rootDir, normalized, "web.json");
109
+ }
110
+ }
111
+ function cleanCwd(value) {
112
+ if (typeof value !== "string" || !value.trim())
113
+ throw new Error("cwd must be a non-empty path");
114
+ return resolve(value.trim());
115
+ }
116
+ function cleanTitle(value) {
117
+ return value?.trim().slice(0, 120) ?? "";
118
+ }
119
+ function parseMeta(value) {
120
+ if (!value || typeof value !== "object" || Array.isArray(value))
121
+ throw new Error("Invalid web session metadata");
122
+ const meta = value;
123
+ if (meta.schemaVersion !== 1)
124
+ throw new Error("Unsupported web session metadata");
125
+ for (const field of ["sessionId", "title", "cwd", "model", "subModel", "effort", "createdAt", "updatedAt"]) {
126
+ if (typeof meta[field] !== "string")
127
+ throw new Error(`Invalid web session ${field}`);
128
+ }
129
+ return meta;
130
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepccc",
3
- "version": "0.1.27",
3
+ "version": "0.2.0",
4
4
  "description": "A lightweight coding agent with OpenAI-compatible and Anthropic Messages API support.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [