deepccc 0.2.3 → 0.2.5

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.
@@ -1,9 +1,13 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { readFile, realpath, stat } from "node:fs/promises";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { AttachmentStore, buildAttachmentPrompt, detectImageMime, MAX_ATTACHMENTS_PER_MESSAGE, MAX_ATTACHMENT_BYTES, } from "./attachments.js";
2
5
  import { ChatSession } from "./index.js";
3
- import { readBuiltinContextState } from "./context.js";
6
+ import { DEFAULT_BUILTIN_CONTEXT_DIR, normalizeBuiltinSessionId, readBuiltinContextState } from "./context.js";
4
7
  import { WebSessionStore, } from "./web-session-store.js";
5
8
  export class DeepCccWebRuntime {
6
9
  store;
10
+ attachmentStore;
7
11
  loadConfig;
8
12
  sessionFactory;
9
13
  approvalTimeoutMs;
@@ -11,12 +15,18 @@ export class DeepCccWebRuntime {
11
15
  idFactory;
12
16
  agents = new Map();
13
17
  activeRuns = new Map();
18
+ startingRuns = new Map();
19
+ sessionMutations = new Set();
14
20
  events = new Map();
15
21
  listeners = new Map();
22
+ globalListeners = new Set();
16
23
  approvals = new Map();
17
24
  nextEventId = 1;
18
25
  constructor(options) {
19
26
  this.store = options.store ?? new WebSessionStore();
27
+ this.attachmentStore = options.attachmentStore ?? new AttachmentStore(resolve(this.store.rootDir) === resolve(DEFAULT_BUILTIN_CONTEXT_DIR)
28
+ ? {}
29
+ : { rootDir: join(this.store.rootDir, ".attachments") });
20
30
  this.loadConfig = options.loadConfig;
21
31
  this.approvalTimeoutMs = options.approvalTimeoutMs ?? 5 * 60_000;
22
32
  this.now = options.now ?? (() => new Date());
@@ -70,48 +80,134 @@ export class DeepCccWebRuntime {
70
80
  };
71
81
  }
72
82
  async updateSession(sessionId, patch) {
73
- if (this.activeRuns.has(sessionId))
74
- throw new Error("Cannot change session settings while the Agent is running");
75
- const updated = await this.store.update(sessionId, patch);
76
- this.agents.delete(sessionId);
77
- this.emit(sessionId, "session_updated", updated);
78
- return updated;
83
+ if (this.activeRuns.has(sessionId) || this.startingRuns.has(sessionId) || this.sessionMutations.has(sessionId)) {
84
+ throw new Error("Cannot change session settings while another session operation is running");
85
+ }
86
+ this.sessionMutations.add(sessionId);
87
+ try {
88
+ const updated = await this.store.update(sessionId, patch);
89
+ this.agents.delete(sessionId);
90
+ this.emit(sessionId, "session_updated", updated);
91
+ return updated;
92
+ }
93
+ finally {
94
+ this.sessionMutations.delete(sessionId);
95
+ }
79
96
  }
80
97
  async deleteSession(sessionId) {
81
- if (this.activeRuns.has(sessionId))
98
+ if (this.activeRuns.has(sessionId) || this.startingRuns.has(sessionId) || this.sessionMutations.has(sessionId)) {
82
99
  throw new Error("Stop the running session before deleting it");
83
- this.agents.delete(sessionId);
84
- this.events.delete(sessionId);
85
- return this.store.delete(sessionId);
100
+ }
101
+ this.sessionMutations.add(sessionId);
102
+ try {
103
+ this.agents.delete(sessionId);
104
+ const deleted = await this.store.delete(sessionId);
105
+ if (deleted) {
106
+ await this.attachmentStore.deleteSession(sessionId);
107
+ this.emit(sessionId, "session_deleted", { sessionId });
108
+ this.events.delete(sessionId);
109
+ }
110
+ return deleted;
111
+ }
112
+ finally {
113
+ this.sessionMutations.delete(sessionId);
114
+ }
86
115
  }
87
- async sendMessage(sessionId, text) {
88
- const prompt = text.trim();
89
- if (!prompt)
116
+ async sendMessage(sessionId, text, attachmentIds = []) {
117
+ const displayPrompt = text.trim();
118
+ const uniqueAttachmentIds = [...new Set(attachmentIds)];
119
+ if (uniqueAttachmentIds.length > MAX_ATTACHMENTS_PER_MESSAGE) {
120
+ throw new Error(`A message can include at most ${MAX_ATTACHMENTS_PER_MESSAGE} images`);
121
+ }
122
+ if (!displayPrompt && !uniqueAttachmentIds.length)
90
123
  throw new Error("Message must not be empty");
91
- if (this.activeRuns.has(sessionId))
124
+ const controller = new AbortController();
125
+ if (this.activeRuns.has(sessionId) || this.startingRuns.has(sessionId) || this.sessionMutations.has(sessionId)) {
92
126
  throw new Error("This session is already running");
93
- let meta = await this.requireSession(sessionId);
94
- if (meta.title === "新会话" || meta.title === "New session" || meta.title === meta.cwd.split(/[\\/]/).at(-1)) {
95
- meta = await this.store.update(sessionId, { title: prompt.replace(/\s+/g, " ").slice(0, 42) });
96
127
  }
97
- const runId = `run-${this.idFactory()}`;
98
- const controller = new AbortController();
99
- this.events.set(sessionId, []);
100
- this.emit(sessionId, "user", { text: prompt });
101
- this.emit(sessionId, "run_started", { runId });
102
- const promise = this.run(meta, prompt, runId, controller.signal)
103
- .finally(() => this.activeRuns.delete(sessionId));
104
- this.activeRuns.set(sessionId, { runId, controller, promise });
105
- void promise.catch(() => { });
106
- return { runId };
128
+ this.startingRuns.set(sessionId, controller);
129
+ try {
130
+ let meta = await this.requireSession(sessionId);
131
+ const attachments = [];
132
+ for (const attachmentId of uniqueAttachmentIds) {
133
+ const attachment = await this.attachmentStore.get(sessionId, attachmentId);
134
+ if (!attachment)
135
+ throw new Error(`Image attachment not found: ${attachmentId}`);
136
+ attachments.push(attachment);
137
+ }
138
+ const prompt = buildAttachmentPrompt(displayPrompt, attachments);
139
+ if (meta.title === "新会话" || meta.title === "New session" || meta.title === meta.cwd.split(/[\\/]/).at(-1)) {
140
+ const title = displayPrompt || attachments.map((attachment) => attachment.originalName).join(", ") || "图片任务";
141
+ meta = await this.store.update(sessionId, { title: title.replace(/\s+/g, " ").slice(0, 42) });
142
+ }
143
+ if (controller.signal.aborted)
144
+ throw new DOMException("The session start was stopped", "AbortError");
145
+ const runId = `run-${this.idFactory()}`;
146
+ this.events.set(sessionId, []);
147
+ this.emit(sessionId, "user", { text: displayPrompt || "请分析这些图片。", attachments });
148
+ const promise = Promise.resolve()
149
+ .then(() => this.run(meta, prompt, runId, controller.signal))
150
+ .finally(() => {
151
+ this.activeRuns.delete(sessionId);
152
+ this.emit(sessionId, "session_updated", { sessionId, status: "idle" });
153
+ });
154
+ this.activeRuns.set(sessionId, { runId, controller, promise });
155
+ this.emit(sessionId, "run_started", { runId });
156
+ void promise.catch(() => { });
157
+ return { runId };
158
+ }
159
+ finally {
160
+ this.startingRuns.delete(sessionId);
161
+ }
107
162
  }
108
163
  async stopSession(sessionId) {
109
164
  const active = this.activeRuns.get(sessionId);
110
- if (!active)
165
+ const starting = this.startingRuns.get(sessionId);
166
+ if (!active && !starting)
111
167
  return false;
168
+ starting?.abort();
169
+ if (!active)
170
+ return true;
112
171
  active.controller.abort();
172
+ const pending = [...this.approvals.entries()]
173
+ .filter(([, waiter]) => waiter.approval.sessionId === sessionId);
174
+ await Promise.all(pending.map(([approvalId, waiter]) => this.finishApproval(approvalId, waiter, "deny", { stopped: true }).catch(() => { })));
113
175
  return true;
114
176
  }
177
+ async addAttachment(sessionId, input) {
178
+ await this.requireSession(sessionId);
179
+ return this.attachmentStore.save(sessionId, input);
180
+ }
181
+ async readAttachment(sessionId, attachmentId) {
182
+ await this.requireSession(sessionId);
183
+ return this.attachmentStore.read(sessionId, attachmentId);
184
+ }
185
+ async deleteAttachment(sessionId, attachmentId) {
186
+ await this.requireSession(sessionId);
187
+ return this.attachmentStore.delete(sessionId, attachmentId);
188
+ }
189
+ async readArtifact(sessionId, value) {
190
+ const meta = await this.requireSession(sessionId);
191
+ const path = await realpath(resolve(value)).catch(() => null);
192
+ if (!path)
193
+ return null;
194
+ const workspaceRoot = await realpath(meta.cwd).catch(() => resolve(meta.cwd));
195
+ const attachmentDir = join(this.attachmentStore.rootDir, normalizeBuiltinSessionId(sessionId));
196
+ const attachmentRoot = await realpath(attachmentDir).catch(() => resolve(attachmentDir));
197
+ if (!isPathInside(workspaceRoot, path) && !isPathInside(attachmentRoot, path)) {
198
+ throw new Error("Artifact path is outside the session workspace and attachment directory");
199
+ }
200
+ const info = await stat(path).catch(() => null);
201
+ if (!info?.isFile())
202
+ return null;
203
+ if (info.size > MAX_ATTACHMENT_BYTES)
204
+ throw new Error("Artifact image exceeds the 20 MB limit");
205
+ const bytes = await readFile(path);
206
+ const mimeType = detectImageMime(bytes);
207
+ if (!mimeType)
208
+ throw new Error("Artifact is not a supported PNG, JPEG, or WebP image");
209
+ return { path, mimeType, size: bytes.length, bytes };
210
+ }
115
211
  async waitForIdle(sessionId) {
116
212
  await this.activeRuns.get(sessionId)?.promise;
117
213
  }
@@ -119,11 +215,7 @@ export class DeepCccWebRuntime {
119
215
  const waiter = this.approvals.get(approvalId);
120
216
  if (!waiter)
121
217
  return false;
122
- clearTimeout(waiter.timeout);
123
- this.approvals.delete(approvalId);
124
- await this.store.resolveApproval(waiter.approval.sessionId, approvalId, answer);
125
- waiter.resolve(answer);
126
- this.emit(waiter.approval.sessionId, "approval_resolved", { approvalId, answer });
218
+ await this.finishApproval(approvalId, waiter, answer);
127
219
  return true;
128
220
  }
129
221
  subscribe(sessionId, listener) {
@@ -136,6 +228,10 @@ export class DeepCccWebRuntime {
136
228
  this.listeners.delete(sessionId);
137
229
  };
138
230
  }
231
+ subscribeAll(listener) {
232
+ this.globalListeners.add(listener);
233
+ return () => this.globalListeners.delete(listener);
234
+ }
139
235
  async run(meta, prompt, runId, signal) {
140
236
  try {
141
237
  const agent = this.agents.get(meta.sessionId) ?? this.createAgent(meta);
@@ -167,33 +263,60 @@ export class DeepCccWebRuntime {
167
263
  createdAt: this.now().toISOString(),
168
264
  };
169
265
  return new Promise((resolve) => {
266
+ const ready = this.store.addApproval(sessionId, {
267
+ ...approval,
268
+ status: "pending",
269
+ });
270
+ let waiter;
170
271
  const timeout = setTimeout(() => {
171
- this.approvals.delete(approvalId);
172
- void this.store.resolveApproval(sessionId, approvalId, "deny").finally(() => {
173
- resolve("deny");
174
- this.emit(sessionId, "approval_resolved", { approvalId, answer: "deny", timedOut: true });
175
- });
272
+ void this.finishApproval(approvalId, waiter, "deny", { timedOut: true }).catch(() => { });
176
273
  }, this.approvalTimeoutMs);
177
274
  timeout.unref?.();
178
- void this.store.addApproval(sessionId, {
179
- ...approval,
180
- status: "pending",
181
- }).then(() => {
182
- this.approvals.set(approvalId, { approval, resolve, timeout });
275
+ waiter = { approval, resolve, timeout, ready };
276
+ this.approvals.set(approvalId, waiter);
277
+ void ready.then(() => {
278
+ if (this.approvals.get(approvalId) !== waiter)
279
+ return;
183
280
  this.emit(sessionId, "approval", approval);
184
281
  }).catch(() => {
282
+ if (this.approvals.get(approvalId) !== waiter)
283
+ return;
185
284
  clearTimeout(timeout);
186
285
  this.approvals.delete(approvalId);
187
286
  resolve("deny");
188
287
  });
189
288
  });
190
289
  }
290
+ async finishApproval(approvalId, waiter, answer, extra = {}) {
291
+ if (this.approvals.get(approvalId) !== waiter)
292
+ return;
293
+ clearTimeout(waiter.timeout);
294
+ this.approvals.delete(approvalId);
295
+ try {
296
+ await waiter.ready;
297
+ await this.store.resolveApproval(waiter.approval.sessionId, approvalId, answer);
298
+ }
299
+ finally {
300
+ waiter.resolve(answer);
301
+ this.emit(waiter.approval.sessionId, "approval_resolved", { approvalId, answer, ...extra });
302
+ }
303
+ }
191
304
  emit(sessionId, type, data) {
192
- const event = { eventId: this.nextEventId++, at: this.now().toISOString(), type, data };
305
+ const event = { eventId: this.nextEventId++, sessionId, at: this.now().toISOString(), type, data };
193
306
  const events = [...(this.events.get(sessionId) ?? []), event].slice(-500);
194
307
  this.events.set(sessionId, events);
195
- for (const listener of this.listeners.get(sessionId) ?? [])
196
- listener(event);
308
+ this.notifyListeners(this.listeners.get(sessionId) ?? [], event);
309
+ if (["run_started", "run_finished", "session_updated", "session_deleted"].includes(type)) {
310
+ this.notifyListeners(this.globalListeners, event);
311
+ }
312
+ }
313
+ notifyListeners(listeners, event) {
314
+ for (const listener of listeners) {
315
+ try {
316
+ listener(event);
317
+ }
318
+ catch { /* a disconnected SSE client must not affect the Agent run */ }
319
+ }
197
320
  }
198
321
  async requireSession(sessionId) {
199
322
  const meta = await this.store.get(sessionId);
@@ -202,3 +325,7 @@ export class DeepCccWebRuntime {
202
325
  return meta;
203
326
  }
204
327
  }
328
+ function isPathInside(root, candidate) {
329
+ const rel = relative(resolve(root), resolve(candidate));
330
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
331
+ }
@@ -19,7 +19,7 @@ export interface PublicDeepCccConfig {
19
19
  defaultCwd?: string;
20
20
  }
21
21
  export interface DeepCccWebRequestHandlerOptions {
22
- runtime: Pick<DeepCccWebRuntime, "listSessions" | "createSession" | "getSession" | "updateSession" | "deleteSession" | "sendMessage" | "stopSession" | "resolveApproval" | "subscribe">;
22
+ runtime: Pick<DeepCccWebRuntime, "listSessions" | "createSession" | "getSession" | "updateSession" | "deleteSession" | "sendMessage" | "stopSession" | "resolveApproval" | "subscribe" | "subscribeAll" | "addAttachment" | "readAttachment" | "deleteAttachment" | "readArtifact">;
23
23
  getPublicConfig: () => PublicDeepCccConfig;
24
24
  saveConfig: (patch: DeepCccConfigPatch) => Promise<PublicDeepCccConfig> | PublicDeepCccConfig;
25
25
  defaultCwd: string;
@@ -6,6 +6,7 @@ import { stat } from "node:fs/promises";
6
6
  import { createRequire } from "node:module";
7
7
  import { dirname, join } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
+ import { MAX_ATTACHMENT_BYTES } from "./attachments.js";
9
10
  import { DEEPCCC_HOME, loadConfig, saveConfigPatch, } from "./config.js";
10
11
  import { killProcessTree } from "./proc-tree-kill.js";
11
12
  import { DeepCccWebRuntime } from "./web-runtime.js";
@@ -84,6 +85,9 @@ export function createDeepCccWebRequestHandler(options) {
84
85
  if (method === "GET" && path === "/api/sessions") {
85
86
  return jsonReply(res, 200, { ok: true, sessions: await options.runtime.listSessions() });
86
87
  }
88
+ if (method === "GET" && path === "/api/events") {
89
+ return openGlobalEventStream(req, res, options.runtime);
90
+ }
87
91
  if (method === "POST" && path === "/api/sessions") {
88
92
  const body = await readJson(req);
89
93
  const cwd = body.cwd?.trim() || options.defaultCwd;
@@ -91,6 +95,37 @@ export function createDeepCccWebRequestHandler(options) {
91
95
  const session = await options.runtime.createSession({ ...body, cwd });
92
96
  return jsonReply(res, 201, { ok: true, session });
93
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
+ }
94
129
  const sessionMatch = path.match(/^\/api\/sessions\/([^/]+)$/);
95
130
  if (sessionMatch && method === "GET") {
96
131
  return jsonReply(res, 200, { ok: true, session: await options.runtime.getSession(decodeURIComponent(sessionMatch[1])) });
@@ -112,7 +147,10 @@ export function createDeepCccWebRequestHandler(options) {
112
147
  const messagesMatch = path.match(/^\/api\/sessions\/([^/]+)\/messages$/);
113
148
  if (messagesMatch && method === "POST") {
114
149
  const body = await readJson(req);
115
- const result = await options.runtime.sendMessage(decodeURIComponent(messagesMatch[1]), body.text ?? "");
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);
116
154
  return jsonReply(res, 202, { ok: true, ...result });
117
155
  }
118
156
  const stopMatch = path.match(/^\/api\/sessions\/([^/]+)\/stop$/);
@@ -265,6 +303,20 @@ function openEventStream(req, res, sessionId, runtime) {
265
303
  heartbeat.unref?.();
266
304
  req.on("close", () => { clearInterval(heartbeat); unsubscribe(); });
267
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
+ }
268
320
  async function readJson(req) {
269
321
  const chunks = [];
270
322
  let bytes = 0;
@@ -284,6 +336,20 @@ async function readJson(req) {
284
336
  throw new WebHttpError(400, "Invalid JSON body");
285
337
  }
286
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
+ }
287
353
  async function assertDirectory(path) {
288
354
  try {
289
355
  if (!(await stat(path)).isDirectory())
@@ -307,6 +373,16 @@ function textReply(res, status, value, contentType) {
307
373
  res.writeHead(status, { "content-type": contentType, "cache-control": "no-store" });
308
374
  res.end(value);
309
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
+ }
310
386
  class WebHttpError extends Error {
311
387
  status;
312
388
  constructor(status, message) {
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepccc",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "A lightweight coding agent with OpenAI-compatible and Anthropic Messages API support.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [