@cjhyy/code-shell-server 0.9.8 → 0.9.9
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/README.md +85 -23
- package/dist/bin/code-shell-project-runtime.d.ts +2 -0
- package/dist/bin/code-shell-project-runtime.js +8 -0
- package/dist/desktop-web/http-api.d.ts +43 -0
- package/dist/desktop-web/http-api.js +376 -0
- package/dist/hub/approval-lease.d.ts +30 -0
- package/dist/hub/approval-lease.js +89 -0
- package/dist/hub/auth-http.d.ts +23 -0
- package/dist/hub/auth-http.js +259 -0
- package/dist/hub/auth-store.d.ts +52 -0
- package/dist/hub/auth-store.js +316 -0
- package/dist/hub/configuration.d.ts +64 -0
- package/dist/hub/configuration.js +555 -0
- package/dist/hub/files.d.ts +9 -0
- package/dist/hub/files.js +278 -0
- package/dist/hub/mcp-configuration.d.ts +51 -0
- package/dist/hub/mcp-configuration.js +558 -0
- package/dist/hub/mcp-probe.d.ts +24 -0
- package/dist/hub/mcp-probe.js +392 -0
- package/dist/hub/model-probe.d.ts +20 -0
- package/dist/hub/model-probe.js +193 -0
- package/dist/hub/outbound-transport.d.ts +36 -0
- package/dist/hub/outbound-transport.js +122 -0
- package/dist/hub/run-replay.d.ts +42 -0
- package/dist/hub/run-replay.js +89 -0
- package/dist/hub/session-management.d.ts +36 -0
- package/dist/hub/session-management.js +405 -0
- package/dist/hub/skills-management.d.ts +43 -0
- package/dist/hub/skills-management.js +487 -0
- package/dist/hub/uploads.d.ts +22 -0
- package/dist/hub/uploads.js +241 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.desktop-web.d.ts +2 -0
- package/dist/index.desktop-web.js +2 -0
- package/dist/index.js +3 -0
- package/dist/index.links.d.ts +6 -0
- package/dist/index.links.js +6 -0
- package/dist/index.panels.d.ts +9 -0
- package/dist/index.panels.js +8 -0
- package/dist/index.serve.d.ts +1 -0
- package/dist/index.serve.js +1 -0
- package/dist/index.storage.d.ts +2 -0
- package/dist/index.storage.js +2 -0
- package/dist/links/catalog.d.ts +10 -0
- package/dist/links/catalog.js +41 -0
- package/dist/links/device-oauth.d.ts +52 -0
- package/dist/links/device-oauth.js +255 -0
- package/dist/links/http.d.ts +34 -0
- package/dist/links/http.js +144 -0
- package/dist/links/service.d.ts +54 -0
- package/dist/links/service.js +578 -0
- package/dist/links/types.d.ts +6 -0
- package/dist/links/types.js +1 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +5 -2
- package/dist/mobile-remote/pending-approvals.js +2 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +8 -0
- package/dist/mobile-remote/remote-host-manager.js +90 -15
- package/dist/mobile-remote/room-manager.js +17 -5
- package/dist/panels/agent-task-host.d.ts +32 -0
- package/dist/panels/agent-task-host.js +457 -0
- package/dist/panels/agent-task-models.d.ts +27 -0
- package/dist/panels/agent-task-models.js +51 -0
- package/dist/panels/agent-task-service.d.ts +105 -0
- package/dist/panels/agent-task-service.js +283 -0
- package/dist/panels/http.d.ts +34 -0
- package/dist/panels/http.js +37 -0
- package/dist/panels/hub-binding.d.ts +12 -0
- package/dist/panels/hub-binding.js +174 -0
- package/dist/panels/management-http.d.ts +29 -0
- package/dist/panels/management-http.js +119 -0
- package/dist/panels/management.d.ts +39 -0
- package/dist/panels/management.js +494 -0
- package/dist/panels/process-files.d.ts +14 -0
- package/dist/panels/process-files.js +299 -0
- package/dist/panels/process-service.d.ts +96 -0
- package/dist/panels/process-service.js +501 -0
- package/dist/panels/runtime-services.d.ts +24 -0
- package/dist/panels/runtime-services.js +462 -0
- package/dist/panels/runtime.d.ts +51 -0
- package/dist/panels/runtime.js +1063 -0
- package/dist/panels/storage-store.d.ts +9 -0
- package/dist/panels/storage-store.js +154 -0
- package/dist/panels/types.d.ts +52 -0
- package/dist/panels/types.js +1 -0
- package/dist/project-runtime/control-server.d.ts +19 -0
- package/dist/project-runtime/control-server.js +300 -0
- package/dist/project-runtime/docker-provider.d.ts +33 -0
- package/dist/project-runtime/docker-provider.js +487 -0
- package/dist/project-runtime/lock.d.ts +6 -0
- package/dist/project-runtime/lock.js +43 -0
- package/dist/project-runtime/managed-entry.d.ts +10 -0
- package/dist/project-runtime/managed-entry.js +113 -0
- package/dist/project-runtime/manager.d.ts +40 -0
- package/dist/project-runtime/manager.js +249 -0
- package/dist/project-runtime/proxy.d.ts +22 -0
- package/dist/project-runtime/proxy.js +706 -0
- package/dist/project-runtime/registry.d.ts +54 -0
- package/dist/project-runtime/registry.js +286 -0
- package/dist/project-runtime/types.d.ts +43 -0
- package/dist/project-runtime/types.js +1 -0
- package/dist/serve/cli.d.ts +5 -0
- package/dist/serve/cli.js +82 -23
- package/dist/serve/headless-server.d.ts +11 -0
- package/dist/serve/headless-server.js +737 -83
- package/dist/session-titles-store.d.ts +17 -0
- package/dist/session-titles-store.js +174 -0
- package/dist/static-files.js +1 -0
- package/dist/worker-bridge-core.js +17 -6
- package/package.json +19 -5
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { SessionManager } from "@cjhyy/code-shell-core";
|
|
4
|
+
import { replayTranscript, transcriptUserDisplay } from "@cjhyy/code-shell-web";
|
|
5
|
+
import { createSessionTitlesStore, SessionTitleConflictError } from "../session-titles-store.js";
|
|
6
|
+
import { hubJson } from "./uploads.js";
|
|
7
|
+
const ROOT = "/api/v1/sessions";
|
|
8
|
+
const MAX_EXPORT_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
const MAX_STATE_BYTES = 1024 * 1024;
|
|
10
|
+
const SAFE_ID = /^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}$/;
|
|
11
|
+
class SessionRequestError extends Error {
|
|
12
|
+
status;
|
|
13
|
+
constructor(status, message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.status = status;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function safeSessionId(sessionId) {
|
|
19
|
+
if (!SAFE_ID.test(sessionId) || sessionId.includes(".."))
|
|
20
|
+
throw new SessionRequestError(400, "会话标识无效。");
|
|
21
|
+
}
|
|
22
|
+
function boundedSessionFile(rootDir, sessionId, filename, limit) {
|
|
23
|
+
safeSessionId(sessionId);
|
|
24
|
+
let directory;
|
|
25
|
+
let descriptor;
|
|
26
|
+
try {
|
|
27
|
+
const root = realpathSync(rootDir);
|
|
28
|
+
const path = join(root, sessionId);
|
|
29
|
+
const info = lstatSync(path);
|
|
30
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
31
|
+
throw new SessionRequestError(403, "会话存储位置无效。");
|
|
32
|
+
directory = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_DIRECTORY ?? 0));
|
|
33
|
+
const openedDirectory = fstatSync(directory);
|
|
34
|
+
if (openedDirectory.dev !== info.dev || openedDirectory.ino !== info.ino)
|
|
35
|
+
throw new SessionRequestError(409, "会话存储正在变化,请重试。");
|
|
36
|
+
descriptor = openSync(process.platform === "linux"
|
|
37
|
+
? `/proc/self/fd/${directory}/${filename}`
|
|
38
|
+
: join(path, filename), constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
39
|
+
const file = fstatSync(descriptor);
|
|
40
|
+
if (!file.isFile())
|
|
41
|
+
throw new SessionRequestError(403, "会话记录不是普通文件。");
|
|
42
|
+
if (file.size > limit)
|
|
43
|
+
throw new SessionRequestError(413, filename === "transcript.jsonl"
|
|
44
|
+
? "对话记录超过浏览器可读取的 32 MB 上限,请从服务器会话备份中获取完整记录。"
|
|
45
|
+
: "会话状态记录过大,无法在浏览器读取。");
|
|
46
|
+
// Snapshot exactly the checked prefix. An append during a running turn can
|
|
47
|
+
// be picked up by the live overlay without growing this allocation.
|
|
48
|
+
const buffer = Buffer.alloc(file.size);
|
|
49
|
+
let bytes = 0;
|
|
50
|
+
while (bytes < buffer.length) {
|
|
51
|
+
const count = readSync(descriptor, buffer, bytes, buffer.length - bytes, bytes);
|
|
52
|
+
if (!count)
|
|
53
|
+
break;
|
|
54
|
+
bytes += count;
|
|
55
|
+
}
|
|
56
|
+
const currentDirectory = lstatSync(path);
|
|
57
|
+
const currentFile = lstatSync(join(path, filename));
|
|
58
|
+
if (currentDirectory.isSymbolicLink() ||
|
|
59
|
+
currentFile.isSymbolicLink() ||
|
|
60
|
+
currentDirectory.dev !== info.dev ||
|
|
61
|
+
currentDirectory.ino !== info.ino ||
|
|
62
|
+
currentFile.dev !== file.dev ||
|
|
63
|
+
currentFile.ino !== file.ino)
|
|
64
|
+
throw new SessionRequestError(409, "会话存储正在变化,请重试。");
|
|
65
|
+
return buffer.subarray(0, bytes).toString("utf8");
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error.code === "ENOENT")
|
|
69
|
+
throw new SessionRequestError(404, "会话记录不存在或已移动。");
|
|
70
|
+
if (["ELOOP", "ENOTDIR"].includes(error.code ?? ""))
|
|
71
|
+
throw new SessionRequestError(403, "不能读取符号链接中的会话记录。");
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
if (descriptor !== undefined)
|
|
76
|
+
closeSync(descriptor);
|
|
77
|
+
if (directory !== undefined)
|
|
78
|
+
closeSync(directory);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Synchronous, side-effect-free transcript snapshot used by HTTP exports and WS replay. */
|
|
82
|
+
export function readHubTranscript(sessionRootDir, sessionId) {
|
|
83
|
+
const raw = boundedSessionFile(sessionRootDir, sessionId, "transcript.jsonl", MAX_EXPORT_BYTES);
|
|
84
|
+
const lines = raw.split("\n");
|
|
85
|
+
const events = [];
|
|
86
|
+
for (let index = 0; index < lines.length; index++) {
|
|
87
|
+
const line = lines[index].trim();
|
|
88
|
+
if (!line)
|
|
89
|
+
continue;
|
|
90
|
+
try {
|
|
91
|
+
const value = JSON.parse(line);
|
|
92
|
+
if (!value ||
|
|
93
|
+
typeof value !== "object" ||
|
|
94
|
+
Array.isArray(value) ||
|
|
95
|
+
typeof value.type !== "string")
|
|
96
|
+
throw new Error();
|
|
97
|
+
events.push(value);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// The worker may be appending the current event; only a final unterminated
|
|
101
|
+
// fragment can be omitted. Invalid interior records must never disappear silently.
|
|
102
|
+
if (index === lines.length - 1 && !raw.endsWith("\n"))
|
|
103
|
+
break;
|
|
104
|
+
throw new SessionRequestError(422, "对话记录包含损坏内容,请从服务器备份检查完整记录。");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return events;
|
|
108
|
+
}
|
|
109
|
+
/** Host authorization reads only bounded metadata, never a transcript or mutable engine bundle. */
|
|
110
|
+
export function readHubSessionState(sessionRootDir, sessionId) {
|
|
111
|
+
const raw = boundedSessionFile(sessionRootDir, sessionId, "state.json", MAX_STATE_BYTES);
|
|
112
|
+
try {
|
|
113
|
+
const value = JSON.parse(raw);
|
|
114
|
+
if (!value || value.sessionId !== sessionId || typeof value.cwd !== "string")
|
|
115
|
+
throw new Error();
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
throw new SessionRequestError(422, "会话状态包含损坏内容,请从服务器备份检查记录。");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function createHubSessions(options) {
|
|
123
|
+
const manager = new SessionManager(options.sessionRootDir);
|
|
124
|
+
const titles = createSessionTitlesStore(join(options.dataDir, "session-titles.json"), {
|
|
125
|
+
strictMutations: true,
|
|
126
|
+
});
|
|
127
|
+
const cwd = resolve(options.cwd);
|
|
128
|
+
let activeExports = 0;
|
|
129
|
+
function owned(sessionId) {
|
|
130
|
+
safeSessionId(sessionId);
|
|
131
|
+
try {
|
|
132
|
+
// Reading state alone avoids loading a large transcript for a rename.
|
|
133
|
+
const state = readHubSessionState(options.sessionRootDir, sessionId);
|
|
134
|
+
if (!state ||
|
|
135
|
+
state.sessionId !== sessionId ||
|
|
136
|
+
typeof state.cwd !== "string" ||
|
|
137
|
+
resolve(state.cwd) !== cwd ||
|
|
138
|
+
state.parentSessionId ||
|
|
139
|
+
state.ephemeral)
|
|
140
|
+
throw new Error();
|
|
141
|
+
return state;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
throw new SessionRequestError(404, "当前工作区没有这个会话。");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function summary(state, customTitle = "") {
|
|
148
|
+
return {
|
|
149
|
+
sessionId: state.sessionId,
|
|
150
|
+
cwd: state.cwd,
|
|
151
|
+
title: customTitle ||
|
|
152
|
+
hubSessionPreview(state.title) ||
|
|
153
|
+
hubSessionPreview(state.preview) ||
|
|
154
|
+
"新对话",
|
|
155
|
+
customTitle,
|
|
156
|
+
startedAt: state.startedAt,
|
|
157
|
+
lastActiveAt: state.lastActiveAt ?? state.startedAt,
|
|
158
|
+
model: state.model,
|
|
159
|
+
status: state.status,
|
|
160
|
+
turnCount: state.turnCount,
|
|
161
|
+
archivedAt: state.archivedAt ?? null,
|
|
162
|
+
running: options.isRunning(state.sessionId),
|
|
163
|
+
...(state.preview ? { preview: hubSessionPreview(state.preview) } : {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
async function list(params = new URLSearchParams()) {
|
|
167
|
+
const archived = params.get("archived") ?? "false";
|
|
168
|
+
if (!["false", "true", "all"].includes(archived))
|
|
169
|
+
throw new SessionRequestError(400, "归档筛选无效。");
|
|
170
|
+
const query = (params.get("query") ?? "").trim();
|
|
171
|
+
if (query.length > 200)
|
|
172
|
+
throw new SessionRequestError(400, "搜索内容过长。");
|
|
173
|
+
const limit = Number(params.get("limit") ?? 50);
|
|
174
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
175
|
+
throw new SessionRequestError(400, "每页数量必须在 1 到 100 之间。");
|
|
176
|
+
let before;
|
|
177
|
+
const cursor = params.get("cursor");
|
|
178
|
+
if (cursor) {
|
|
179
|
+
try {
|
|
180
|
+
if (cursor.length > 1024)
|
|
181
|
+
throw new Error();
|
|
182
|
+
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
183
|
+
if (!Number.isFinite(parsed.lastActiveAt) || !SAFE_ID.test(parsed.sessionId))
|
|
184
|
+
throw new Error();
|
|
185
|
+
before = { lastActiveAt: parsed.lastActiveAt, sessionId: parsed.sessionId };
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
throw new SessionRequestError(400, "分页位置无效,请刷新会话列表。");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const customTitles = await titles.listTitles();
|
|
192
|
+
const search = query.toLocaleLowerCase();
|
|
193
|
+
const entries = manager.list(limit + 1, {
|
|
194
|
+
cwd,
|
|
195
|
+
rootsOnly: true,
|
|
196
|
+
...(archived !== "all" ? { archived: archived === "true" } : {}),
|
|
197
|
+
...(before ? { before } : {}),
|
|
198
|
+
filter: search
|
|
199
|
+
? (state) => `${customTitles[state.sessionId] ?? ""}\n${hubSessionPreview(state.title) ?? ""}\n${hubSessionPreview(state.preview) ?? ""}\n${state.sessionId}`
|
|
200
|
+
.toLocaleLowerCase()
|
|
201
|
+
.includes(search)
|
|
202
|
+
: undefined,
|
|
203
|
+
});
|
|
204
|
+
const page = entries.slice(0, limit);
|
|
205
|
+
const last = page.at(-1);
|
|
206
|
+
return {
|
|
207
|
+
sessions: page.map((state) => summary(state, customTitles[state.sessionId])),
|
|
208
|
+
nextCursor: entries.length > limit && last
|
|
209
|
+
? Buffer.from(JSON.stringify({ lastActiveAt: last.lastActiveAt, sessionId: last.sessionId })).toString("base64url")
|
|
210
|
+
: null,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
async function handle(req, res) {
|
|
214
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
215
|
+
if (url.pathname !== ROOT && !url.pathname.startsWith(`${ROOT}/`))
|
|
216
|
+
return false;
|
|
217
|
+
let exporting = false;
|
|
218
|
+
try {
|
|
219
|
+
if (!(await options.isAuthorized(req)))
|
|
220
|
+
throw new SessionRequestError(401, "请重新登录。");
|
|
221
|
+
if (url.pathname === ROOT && req.method === "GET") {
|
|
222
|
+
const result = await list(url.searchParams);
|
|
223
|
+
if (!(await options.isAuthorized(req)))
|
|
224
|
+
throw new SessionRequestError(401, "请重新登录。");
|
|
225
|
+
hubJson(res, 200, result);
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
const match = /^\/api\/v1\/sessions\/([^/]+)(\/export)?$/.exec(url.pathname);
|
|
229
|
+
if (!match)
|
|
230
|
+
throw new SessionRequestError(404, "没有这个会话操作。");
|
|
231
|
+
let sessionId;
|
|
232
|
+
try {
|
|
233
|
+
sessionId = decodeURIComponent(match[1]);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
throw new SessionRequestError(400, "会话标识无效。");
|
|
237
|
+
}
|
|
238
|
+
const state = owned(sessionId);
|
|
239
|
+
if (!match[2] && req.method === "GET") {
|
|
240
|
+
const customTitles = await titles.listTitles();
|
|
241
|
+
if (!(await options.isAuthorized(req)))
|
|
242
|
+
throw new SessionRequestError(401, "请重新登录。");
|
|
243
|
+
hubJson(res, 200, summary(owned(sessionId), customTitles[sessionId]));
|
|
244
|
+
}
|
|
245
|
+
else if (match[2] && req.method === "GET") {
|
|
246
|
+
if (options.isRunning(sessionId))
|
|
247
|
+
throw new SessionRequestError(409, "请等待当前任务完成后再导出完整对话。");
|
|
248
|
+
if (activeExports >= 2)
|
|
249
|
+
throw new SessionRequestError(429, "正在导出的对话过多,请稍后重试。");
|
|
250
|
+
activeExports++;
|
|
251
|
+
exporting = true;
|
|
252
|
+
const transcript = readHubTranscript(options.sessionRootDir, sessionId);
|
|
253
|
+
const format = url.searchParams.get("format") ?? "markdown";
|
|
254
|
+
if (format !== "json" && format !== "markdown")
|
|
255
|
+
throw new SessionRequestError(400, "不支持这个导出格式。");
|
|
256
|
+
const title = (await titles.listTitles())[sessionId] ||
|
|
257
|
+
hubSessionPreview(state.title) ||
|
|
258
|
+
"CodeShell 对话";
|
|
259
|
+
const body = format === "json"
|
|
260
|
+
? JSON.stringify({ version: 1, title, state, transcript }, null, 2) + "\n"
|
|
261
|
+
: transcriptMarkdown(title, transcript);
|
|
262
|
+
if (Buffer.byteLength(body) > MAX_EXPORT_BYTES)
|
|
263
|
+
throw new SessionRequestError(413, "导出内容过大。");
|
|
264
|
+
if (!(await options.isAuthorized(req)))
|
|
265
|
+
throw new SessionRequestError(401, "请重新登录。");
|
|
266
|
+
res.writeHead(200, {
|
|
267
|
+
"content-type": format === "json" ? "application/json; charset=utf-8" : "text/markdown; charset=utf-8",
|
|
268
|
+
"content-disposition": `attachment; filename="codeshell-${sessionId}.${format === "json" ? "json" : "md"}"`,
|
|
269
|
+
"cache-control": "no-store",
|
|
270
|
+
"x-content-type-options": "nosniff",
|
|
271
|
+
});
|
|
272
|
+
res.end(body);
|
|
273
|
+
}
|
|
274
|
+
else if (!match[2] && req.method === "PATCH") {
|
|
275
|
+
const input = await readBody(req);
|
|
276
|
+
if (!(await options.isAuthorized(req)))
|
|
277
|
+
throw new SessionRequestError(401, "请重新登录。");
|
|
278
|
+
owned(sessionId);
|
|
279
|
+
if ("title" in input) {
|
|
280
|
+
if (Object.keys(input).some((key) => key !== "title" && key !== "expectedTitle"))
|
|
281
|
+
throw new SessionRequestError(400, "每次只修改一个会话属性。");
|
|
282
|
+
if (typeof input.title !== "string" ||
|
|
283
|
+
input.title.length > 1024 ||
|
|
284
|
+
/[\0\r\n]/.test(input.title))
|
|
285
|
+
throw new SessionRequestError(400, "标题必须是 1024 字以内的单行文字。");
|
|
286
|
+
if (input.expectedTitle !== undefined &&
|
|
287
|
+
input.expectedTitle !== null &&
|
|
288
|
+
(typeof input.expectedTitle !== "string" ||
|
|
289
|
+
input.expectedTitle.length > 1024 ||
|
|
290
|
+
input.expectedTitle.includes("\0")))
|
|
291
|
+
throw new SessionRequestError(400, "用于比较的原标题无效,请重新读取会话。");
|
|
292
|
+
await titles.setTitle(sessionId, input.title.trim(), {
|
|
293
|
+
expectedTitle: input.expectedTitle,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
else if (Object.keys(input).length === 1 && typeof input.archived === "boolean") {
|
|
297
|
+
if (options.isRunning(sessionId))
|
|
298
|
+
throw new SessionRequestError(409, "请等待当前任务完成后再归档或恢复会话。");
|
|
299
|
+
manager.setSessionArchived(sessionId, input.archived ? Date.now() : undefined);
|
|
300
|
+
}
|
|
301
|
+
else
|
|
302
|
+
throw new SessionRequestError(400, "不支持这个会话属性。");
|
|
303
|
+
options.onChanged(sessionId);
|
|
304
|
+
hubJson(res, 200, { ok: true });
|
|
305
|
+
}
|
|
306
|
+
else
|
|
307
|
+
throw new SessionRequestError(405, "不支持这个请求方式。");
|
|
308
|
+
}
|
|
309
|
+
catch (cause) {
|
|
310
|
+
hubJson(res, cause instanceof SessionTitleConflictError
|
|
311
|
+
? 409
|
|
312
|
+
: cause instanceof SessionRequestError
|
|
313
|
+
? cause.status
|
|
314
|
+
: 500, {
|
|
315
|
+
error: cause instanceof SessionTitleConflictError
|
|
316
|
+
? "另一设备已修改标题。你的草稿已保留,请读取最新标题后再确认保存。"
|
|
317
|
+
: cause instanceof SessionRequestError
|
|
318
|
+
? cause.message
|
|
319
|
+
: "会话操作失败,原有记录仍保留在服务器。",
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
if (exporting)
|
|
324
|
+
activeExports--;
|
|
325
|
+
}
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
return { handle, list };
|
|
329
|
+
}
|
|
330
|
+
function fenced(content, language = "") {
|
|
331
|
+
let longest = 2;
|
|
332
|
+
for (const match of content.matchAll(/`+/g))
|
|
333
|
+
longest = Math.max(longest, match[0].length);
|
|
334
|
+
const fence = "`".repeat(longest + 1);
|
|
335
|
+
return `${fence}${language}\n${content}\n${fence}`;
|
|
336
|
+
}
|
|
337
|
+
/** Keep engine attachment wrappers out of title/search/preview surfaces. */
|
|
338
|
+
export function hubSessionPreview(value) {
|
|
339
|
+
if (typeof value !== "string" || !value)
|
|
340
|
+
return undefined;
|
|
341
|
+
// Core bounds optional preview text. A clipped attachment block is metadata,
|
|
342
|
+
// even when its closing tag fell outside that bound; never expose its paths.
|
|
343
|
+
const source = value.replace(/<attached-(file|directory|image-paths)\b[^>]*>(?:(?!<\/attached-\1>)[\s\S])*$/g, "");
|
|
344
|
+
const display = transcriptUserDisplay({ content: source });
|
|
345
|
+
return ((display.text ||
|
|
346
|
+
display.attachments.map((file) => `📎 ${file.name}`).join(" · ") ||
|
|
347
|
+
(source !== value ? "📎 附件" : ""))
|
|
348
|
+
.replace(/\s+/g, " ")
|
|
349
|
+
.trim()
|
|
350
|
+
.slice(0, 240) || undefined);
|
|
351
|
+
}
|
|
352
|
+
export function transcriptMarkdown(title, transcript) {
|
|
353
|
+
const sections = [`# ${title.replace(/[\r\n]/g, " ")}`];
|
|
354
|
+
for (const item of replayTranscript(transcript).items) {
|
|
355
|
+
if (item.kind === "user") {
|
|
356
|
+
sections.push(`## 用户\n\n${item.text}`);
|
|
357
|
+
if (item.attachments?.length)
|
|
358
|
+
sections.push(item.attachments.map((file) => `- 附件:${file.name}(${file.size} 字节)`).join("\n"));
|
|
359
|
+
}
|
|
360
|
+
else if (item.kind === "assistant") {
|
|
361
|
+
sections.push(`## ${item.agentId ? `子代理 ${item.agentId}` : "助手"}\n\n${item.text}`);
|
|
362
|
+
if (item.reasoning)
|
|
363
|
+
sections.push(`### 推理\n\n${item.reasoning}`);
|
|
364
|
+
}
|
|
365
|
+
else if (item.kind === "tool") {
|
|
366
|
+
sections.push(`### 工具:${item.name}\n\n${fenced(JSON.stringify(item.args ?? {}, null, 2), "json")}`);
|
|
367
|
+
if (item.result)
|
|
368
|
+
sections.push(fenced(item.result));
|
|
369
|
+
}
|
|
370
|
+
else if (item.kind === "system_error")
|
|
371
|
+
sections.push(`### 错误\n\n${item.text}`);
|
|
372
|
+
else
|
|
373
|
+
sections.push(`### 子代理:${item.label}\n\n${item.status}`);
|
|
374
|
+
}
|
|
375
|
+
return sections.join("\n\n") + "\n";
|
|
376
|
+
}
|
|
377
|
+
async function readBody(req) {
|
|
378
|
+
if (req.headers["content-type"]?.split(";", 1)[0]?.trim() !== "application/json")
|
|
379
|
+
throw new SessionRequestError(415, "请求必须使用 JSON 格式。");
|
|
380
|
+
const chunks = [];
|
|
381
|
+
let bytes = 0;
|
|
382
|
+
const timer = setTimeout(() => req.destroy(), 10_000);
|
|
383
|
+
timer.unref();
|
|
384
|
+
try {
|
|
385
|
+
for await (const chunk of req) {
|
|
386
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
387
|
+
bytes += value.length;
|
|
388
|
+
if (bytes > 8192)
|
|
389
|
+
throw new SessionRequestError(413, "请求内容过大。");
|
|
390
|
+
chunks.push(value);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
clearTimeout(timer);
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
398
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
399
|
+
throw new Error();
|
|
400
|
+
return value;
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
throw new SessionRequestError(400, "请求不是有效的 JSON 对象。");
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { inspectRepo, getRefCommit, downloadSkillTree } from "@cjhyy/code-shell-core/internal/skills";
|
|
3
|
+
export interface HubSkillsOptions {
|
|
4
|
+
cwd: string;
|
|
5
|
+
dataDir: string;
|
|
6
|
+
owner: (request: IncomingMessage) => Promise<string | null>;
|
|
7
|
+
isAuthorized?: (request: IncomingMessage) => Promise<boolean>;
|
|
8
|
+
withMutation?: <T>(write: () => Promise<T>) => Promise<T>;
|
|
9
|
+
now?: () => number;
|
|
10
|
+
github?: {
|
|
11
|
+
inspect: typeof inspectRepo;
|
|
12
|
+
commit: typeof getRefCommit;
|
|
13
|
+
download: typeof downloadSkillTree;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
type Origin = {
|
|
17
|
+
kind: "github";
|
|
18
|
+
url: string;
|
|
19
|
+
ref: string;
|
|
20
|
+
commit: string;
|
|
21
|
+
};
|
|
22
|
+
export interface HubManagedSkill {
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
source: "project" | "user" | "plugin" | "panel-app";
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
editable: boolean;
|
|
28
|
+
removable: boolean;
|
|
29
|
+
revision: string;
|
|
30
|
+
origin?: Origin;
|
|
31
|
+
disabledReason?: string;
|
|
32
|
+
readOnlyReason?: string;
|
|
33
|
+
}
|
|
34
|
+
/** Dispatch only after the host's authenticated same-origin API boundary. */
|
|
35
|
+
export declare function createHubSkills(options: HubSkillsOptions): {
|
|
36
|
+
handle: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
|
37
|
+
snapshot: () => {
|
|
38
|
+
skills: HubManagedSkill[];
|
|
39
|
+
directories: string[];
|
|
40
|
+
};
|
|
41
|
+
close: () => Promise<void>;
|
|
42
|
+
};
|
|
43
|
+
export {};
|