@super-coding/cli 0.0.0-stage → 0.1.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.
- package/README.md +70 -2
- package/dist/cli.js +35 -0
- package/dist/daemon/adapters/base.js +37 -0
- package/dist/daemon/adapters/claude-code.js +184 -0
- package/dist/daemon/adapters/cursor.js +138 -0
- package/dist/daemon/adapters/features.js +51 -0
- package/dist/daemon/adapters/registry.js +9 -0
- package/dist/daemon/agent/builtin/agent.md +164 -0
- package/dist/daemon/agent/builtin/load.js +121 -0
- package/dist/daemon/agent/builtin/parse.js +73 -0
- package/dist/daemon/agent/index.js +60 -0
- package/dist/daemon/agent/manifest.js +72 -0
- package/dist/daemon/agent/materialize.js +220 -0
- package/dist/daemon/agent/renderers/claude-code.js +16 -0
- package/dist/daemon/agent/renderers/common.js +61 -0
- package/dist/daemon/agent/renderers/cursor.js +13 -0
- package/dist/daemon/agent/renderers/generic.js +5 -0
- package/dist/daemon/agent/resolve.js +212 -0
- package/dist/daemon/agent/slug.js +27 -0
- package/dist/daemon/agent/types.js +8 -0
- package/dist/daemon/device-store.js +61 -0
- package/dist/daemon/index.js +280 -0
- package/dist/daemon/manage.js +183 -0
- package/dist/daemon/orchestrator.js +364 -0
- package/dist/daemon/pairing.js +93 -0
- package/dist/daemon/paths.js +47 -0
- package/dist/daemon/rpc.js +41 -0
- package/dist/daemon/run-skills.js +148 -0
- package/dist/daemon/services/agent-config.js +1 -0
- package/dist/daemon/services/claude-provider.js +126 -0
- package/dist/daemon/services/cursor-provider.js +94 -0
- package/dist/daemon/services/json-file.js +21 -0
- package/dist/daemon/services/log-buffer.js +43 -0
- package/dist/daemon/services/mcp-secrets.js +36 -0
- package/dist/daemon/session/store.js +137 -0
- package/dist/daemon/transport/ws-client.js +121 -0
- package/package.json +44 -3
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
3
|
+
const MANIFEST = join(".super-coding", "managed-skills.json");
|
|
4
|
+
const SLUG = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
5
|
+
function skillRoot(agent) {
|
|
6
|
+
if (agent === "claude-code")
|
|
7
|
+
return ".claude/skills";
|
|
8
|
+
if (agent === "cursor")
|
|
9
|
+
return ".cursor/skills";
|
|
10
|
+
return ".agents/skills";
|
|
11
|
+
}
|
|
12
|
+
function isSafeSlug(slug) {
|
|
13
|
+
return SLUG.test(slug) && !slug.includes("..");
|
|
14
|
+
}
|
|
15
|
+
function frontmatterName(content) {
|
|
16
|
+
const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
17
|
+
const name = block?.[1].match(/^name:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
18
|
+
return name || undefined;
|
|
19
|
+
}
|
|
20
|
+
function modelInvocationDisabled(content) {
|
|
21
|
+
const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
22
|
+
if (!block)
|
|
23
|
+
return false;
|
|
24
|
+
return /^disable-model-invocation:\s*true\s*$/im.test(block[1]);
|
|
25
|
+
}
|
|
26
|
+
function preferredSlug(skill) {
|
|
27
|
+
if (isSafeSlug(skill.name))
|
|
28
|
+
return skill.name;
|
|
29
|
+
const fromFile = frontmatterName(skill.content);
|
|
30
|
+
if (fromFile && isSafeSlug(fromFile))
|
|
31
|
+
return fromFile;
|
|
32
|
+
const id = skill.id.replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase();
|
|
33
|
+
return `skill-${id || "x"}`;
|
|
34
|
+
}
|
|
35
|
+
function allocateSlugs(skills) {
|
|
36
|
+
const taken = new Set();
|
|
37
|
+
return skills.map((skill) => {
|
|
38
|
+
const base = preferredSlug(skill);
|
|
39
|
+
let slug = base;
|
|
40
|
+
for (let n = 2; taken.has(slug.toLowerCase()); n += 1) {
|
|
41
|
+
if (n > 100)
|
|
42
|
+
throw new Error(`skill slug exhausted: ${base}`);
|
|
43
|
+
const suffix = `-${n}`;
|
|
44
|
+
slug = `${base.slice(0, Math.max(1, 64 - suffix.length))}${suffix}`;
|
|
45
|
+
}
|
|
46
|
+
taken.add(slug.toLowerCase());
|
|
47
|
+
return slug;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function yamlScalar(value) {
|
|
51
|
+
if (/[:#\n"'&*!?|>%@`]|^\s|\s$/.test(value))
|
|
52
|
+
return JSON.stringify(value);
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
function safeSkillFilePath(path) {
|
|
56
|
+
const p = path.trim();
|
|
57
|
+
if (!p || p.length > 240 || p === "SKILL.md")
|
|
58
|
+
return null;
|
|
59
|
+
if (p.startsWith("/") || p.includes("\\") || /[\u0000-\u001f]/.test(p))
|
|
60
|
+
return null;
|
|
61
|
+
const parts = p.split("/");
|
|
62
|
+
if (parts.some((part) => !part || part === "." || part === ".."))
|
|
63
|
+
return null;
|
|
64
|
+
return p;
|
|
65
|
+
}
|
|
66
|
+
function isInside(root, dest) {
|
|
67
|
+
const rel = relative(root, dest);
|
|
68
|
+
return rel !== "" && !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`);
|
|
69
|
+
}
|
|
70
|
+
function skillDocument(skill, slug) {
|
|
71
|
+
const content = skill.content.replace(/^\uFEFF/, "");
|
|
72
|
+
if (/^---\r?\n/.test(content))
|
|
73
|
+
return content.endsWith("\n") ? content : `${content}\n`;
|
|
74
|
+
const description = skill.description?.trim();
|
|
75
|
+
const head = [
|
|
76
|
+
"---",
|
|
77
|
+
`name: ${yamlScalar(slug)}`,
|
|
78
|
+
...(description ? [`description: ${yamlScalar(description)}`] : []),
|
|
79
|
+
"---",
|
|
80
|
+
"",
|
|
81
|
+
].join("\n");
|
|
82
|
+
return content ? `${head}${content.endsWith("\n") ? content : `${content}\n`}` : head;
|
|
83
|
+
}
|
|
84
|
+
async function readManifest(path) {
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
87
|
+
return Array.isArray(parsed.slugs) ? parsed.slugs.filter((slug) => typeof slug === "string") : [];
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 把本轮 skill 写进会话目录里执行器自己的 skills 目录,并清掉上一轮已取消关联的目录。
|
|
95
|
+
* 返回要写进提示词的名单(disable-model-invocation 的仍落盘,但不进名单)。
|
|
96
|
+
*/
|
|
97
|
+
export async function syncRunSkills(workdir, agent, skills) {
|
|
98
|
+
const rootRel = skillRoot(agent);
|
|
99
|
+
const root = join(workdir, ...rootRel.split("/"));
|
|
100
|
+
const previous = await readManifest(join(workdir, MANIFEST));
|
|
101
|
+
const slugs = allocateSlugs(skills);
|
|
102
|
+
const next = new Set(slugs.map((slug) => slug.toLowerCase()));
|
|
103
|
+
for (const slug of previous) {
|
|
104
|
+
if (!isSafeSlug(slug) || next.has(slug.toLowerCase()))
|
|
105
|
+
continue;
|
|
106
|
+
await rm(join(root, slug), { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
const visible = [];
|
|
109
|
+
for (let i = 0; i < skills.length; i += 1) {
|
|
110
|
+
const skill = skills[i];
|
|
111
|
+
const slug = slugs[i];
|
|
112
|
+
const dir = join(root, slug);
|
|
113
|
+
await rm(dir, { recursive: true, force: true });
|
|
114
|
+
await mkdir(dir, { recursive: true });
|
|
115
|
+
await writeFile(join(dir, "SKILL.md"), skillDocument(skill, slug));
|
|
116
|
+
for (const file of skill.files ?? []) {
|
|
117
|
+
const rel = safeSkillFilePath(file.path);
|
|
118
|
+
if (!rel)
|
|
119
|
+
continue;
|
|
120
|
+
const dest = join(dir, ...rel.split("/"));
|
|
121
|
+
if (!isInside(dir, dest))
|
|
122
|
+
continue;
|
|
123
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
124
|
+
await writeFile(dest, file.content ?? "");
|
|
125
|
+
}
|
|
126
|
+
if (!modelInvocationDisabled(skill.content)) {
|
|
127
|
+
visible.push({
|
|
128
|
+
slug,
|
|
129
|
+
...(skill.description ? { description: skill.description } : {}),
|
|
130
|
+
relativePath: `${rootRel}/${slug}/SKILL.md`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
await mkdir(join(workdir, ".super-coding"), { recursive: true });
|
|
135
|
+
await writeFile(join(workdir, MANIFEST), JSON.stringify({ slugs }) + "\n");
|
|
136
|
+
return visible;
|
|
137
|
+
}
|
|
138
|
+
/** 只列名称和路径。正文在 SKILL.md 里,由执行器自己读。 */
|
|
139
|
+
export function promptWithSkills(prompt, skills) {
|
|
140
|
+
if (skills.length === 0)
|
|
141
|
+
return prompt;
|
|
142
|
+
const lines = skills.map((skill) => {
|
|
143
|
+
const description = skill.description ? `:${skill.description}` : "";
|
|
144
|
+
return `- ${skill.slug}${description}(${skill.relativePath})`;
|
|
145
|
+
});
|
|
146
|
+
const index = `可用技能:\n${lines.join("\n")}`;
|
|
147
|
+
return prompt ? `${prompt}\n\n${index}` : index;
|
|
148
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { RpcException } from "../rpc.js";
|
|
5
|
+
import { isValidName, readJson, writeJsonAtomic } from "./json-file.js";
|
|
6
|
+
import { maskMcpServers, mergeMcpSecrets } from "./mcp-secrets.js";
|
|
7
|
+
const asStr = (v) => (typeof v === "string" ? v : undefined);
|
|
8
|
+
/** 从 SKILL.md frontmatter 提取 description */
|
|
9
|
+
const skillDescription = (content) => content.match(/^description:\s*(.+)$/m)?.[1]?.trim();
|
|
10
|
+
/**
|
|
11
|
+
* claude-code 配置面:
|
|
12
|
+
* - config → ~/.claude/settings.json(model / permissions.defaultMode)
|
|
13
|
+
* - skill → ~/.claude/skills/<name>/SKILL.md
|
|
14
|
+
* - mcp → ~/.claude.json 的 mcpServers
|
|
15
|
+
* - memory → global: ~/.claude/CLAUDE.md;project: <cwd>/CLAUDE.md
|
|
16
|
+
*/
|
|
17
|
+
export class ClaudeConfigProvider {
|
|
18
|
+
home;
|
|
19
|
+
agent_id = "claude-code";
|
|
20
|
+
available_models = ["default", "sonnet", "opus", "haiku"];
|
|
21
|
+
constructor(home = homedir()) {
|
|
22
|
+
this.home = home;
|
|
23
|
+
}
|
|
24
|
+
settingsPath() {
|
|
25
|
+
return join(this.home, ".claude", "settings.json");
|
|
26
|
+
}
|
|
27
|
+
mcpPath() {
|
|
28
|
+
return join(this.home, ".claude.json");
|
|
29
|
+
}
|
|
30
|
+
skillsDir() {
|
|
31
|
+
return join(this.home, ".claude", "skills");
|
|
32
|
+
}
|
|
33
|
+
memoryPath(scope, cwd) {
|
|
34
|
+
return scope === "global"
|
|
35
|
+
? join(this.home, ".claude", "CLAUDE.md")
|
|
36
|
+
: join(cwd ?? process.cwd(), "CLAUDE.md");
|
|
37
|
+
}
|
|
38
|
+
async getConfig() {
|
|
39
|
+
const j = await readJson(this.settingsPath());
|
|
40
|
+
return { model: asStr(j?.model), permission_mode: asStr(j?.permissions?.defaultMode) };
|
|
41
|
+
}
|
|
42
|
+
async setConfig(patch) {
|
|
43
|
+
const j = (await readJson(this.settingsPath())) ?? {};
|
|
44
|
+
if (patch.model !== undefined)
|
|
45
|
+
j.model = patch.model;
|
|
46
|
+
if (patch.permission_mode !== undefined) {
|
|
47
|
+
const permissions = j.permissions ?? {};
|
|
48
|
+
permissions.defaultMode = patch.permission_mode;
|
|
49
|
+
j.permissions = permissions;
|
|
50
|
+
}
|
|
51
|
+
await writeJsonAtomic(this.settingsPath(), j);
|
|
52
|
+
return this.getConfig();
|
|
53
|
+
}
|
|
54
|
+
async listSkills() {
|
|
55
|
+
let entries;
|
|
56
|
+
try {
|
|
57
|
+
entries = await readdir(this.skillsDir());
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const skills = [];
|
|
63
|
+
for (const name of entries) {
|
|
64
|
+
try {
|
|
65
|
+
const content = await readFile(join(this.skillsDir(), name, "SKILL.md"), "utf8");
|
|
66
|
+
skills.push({ name, description: skillDescription(content) });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// 无 SKILL.md 的目录跳过
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return skills;
|
|
73
|
+
}
|
|
74
|
+
async installSkill(name, content) {
|
|
75
|
+
if (!isValidName(name))
|
|
76
|
+
throw new RpcException("bad_params", `invalid skill name: ${name}`);
|
|
77
|
+
const dir = join(this.skillsDir(), name);
|
|
78
|
+
await mkdir(dir, { recursive: true });
|
|
79
|
+
await writeFile(join(dir, "SKILL.md"), content);
|
|
80
|
+
}
|
|
81
|
+
async removeSkill(name) {
|
|
82
|
+
if (!isValidName(name))
|
|
83
|
+
throw new RpcException("bad_params", `invalid skill name: ${name}`);
|
|
84
|
+
await rm(join(this.skillsDir(), name), { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
async listMcp() {
|
|
87
|
+
const j = await readJson(this.mcpPath());
|
|
88
|
+
// env/headers 的值可能是 API Key:对外只回键名(脱敏后仍可重建配置)
|
|
89
|
+
return maskMcpServers(j?.mcpServers ?? {});
|
|
90
|
+
}
|
|
91
|
+
async addMcp(name, spec) {
|
|
92
|
+
if (!isValidName(name))
|
|
93
|
+
throw new RpcException("bad_params", `invalid mcp name: ${name}`);
|
|
94
|
+
const j = (await readJson(this.mcpPath())) ?? {};
|
|
95
|
+
const servers = (j.mcpServers ?? {});
|
|
96
|
+
// 前端编辑后回写:值为掩码的密钥沿用旧值,不把 *** 落盘
|
|
97
|
+
servers[name] = mergeMcpSecrets(spec, servers[name]);
|
|
98
|
+
j.mcpServers = servers;
|
|
99
|
+
await writeJsonAtomic(this.mcpPath(), j);
|
|
100
|
+
}
|
|
101
|
+
async removeMcp(name) {
|
|
102
|
+
const j = await readJson(this.mcpPath());
|
|
103
|
+
const servers = j?.mcpServers;
|
|
104
|
+
if (!servers || !(name in servers))
|
|
105
|
+
return;
|
|
106
|
+
delete servers[name];
|
|
107
|
+
await writeJsonAtomic(this.mcpPath(), j);
|
|
108
|
+
}
|
|
109
|
+
async getMemory(scope, cwd) {
|
|
110
|
+
const path = this.memoryPath(scope, cwd);
|
|
111
|
+
let content = "";
|
|
112
|
+
try {
|
|
113
|
+
content = await readFile(path, "utf8");
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// 文件不存在按空内容
|
|
117
|
+
}
|
|
118
|
+
return { path, content };
|
|
119
|
+
}
|
|
120
|
+
async putMemory(scope, content, cwd) {
|
|
121
|
+
const path = this.memoryPath(scope, cwd);
|
|
122
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
123
|
+
await writeFile(path, content);
|
|
124
|
+
return { path };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { RpcException } from "../rpc.js";
|
|
5
|
+
import { isValidName, readJson, writeJsonAtomic } from "./json-file.js";
|
|
6
|
+
import { maskMcpServers, mergeMcpSecrets } from "./mcp-secrets.js";
|
|
7
|
+
/**
|
|
8
|
+
* cursor 配置面:
|
|
9
|
+
* - config → daemon 自有 ~/.super-coding/agent-config.json(cursor 无持久设置文件,作为 spawn 默认 --model)
|
|
10
|
+
* - skill → 不支持(.cursor/rules 无 skill 概念),unsupported
|
|
11
|
+
* - mcp → ~/.cursor/mcp.json 的 mcpServers
|
|
12
|
+
* - memory → global: ~/.cursor/rules/super-coding.mdc;project: <cwd>/.cursor/rules/super-coding.mdc
|
|
13
|
+
*/
|
|
14
|
+
export class CursorConfigProvider {
|
|
15
|
+
home;
|
|
16
|
+
agent_id = "cursor";
|
|
17
|
+
available_models = ["auto"];
|
|
18
|
+
constructor(home = homedir()) {
|
|
19
|
+
this.home = home;
|
|
20
|
+
}
|
|
21
|
+
daemonConfigPath() {
|
|
22
|
+
return join(this.home, ".super-coding", "agent-config.json");
|
|
23
|
+
}
|
|
24
|
+
mcpPath() {
|
|
25
|
+
return join(this.home, ".cursor", "mcp.json");
|
|
26
|
+
}
|
|
27
|
+
memoryPath(scope, cwd) {
|
|
28
|
+
return scope === "global"
|
|
29
|
+
? join(this.home, ".cursor", "rules", "super-coding.mdc")
|
|
30
|
+
: join(cwd ?? process.cwd(), ".cursor", "rules", "super-coding.mdc");
|
|
31
|
+
}
|
|
32
|
+
async getConfig() {
|
|
33
|
+
const j = await readJson(this.daemonConfigPath());
|
|
34
|
+
return j?.cursor ?? {};
|
|
35
|
+
}
|
|
36
|
+
async setConfig(patch) {
|
|
37
|
+
const j = (await readJson(this.daemonConfigPath())) ?? {};
|
|
38
|
+
j.cursor = { ...j.cursor, ...patch };
|
|
39
|
+
await writeJsonAtomic(this.daemonConfigPath(), j);
|
|
40
|
+
return this.getConfig();
|
|
41
|
+
}
|
|
42
|
+
unsupported() {
|
|
43
|
+
throw new RpcException("unsupported", "cursor 不支持 skill 管理");
|
|
44
|
+
}
|
|
45
|
+
async listSkills() {
|
|
46
|
+
this.unsupported();
|
|
47
|
+
}
|
|
48
|
+
async installSkill() {
|
|
49
|
+
this.unsupported();
|
|
50
|
+
}
|
|
51
|
+
async removeSkill() {
|
|
52
|
+
this.unsupported();
|
|
53
|
+
}
|
|
54
|
+
async listMcp() {
|
|
55
|
+
const j = await readJson(this.mcpPath());
|
|
56
|
+
// env/headers 的值可能是 API Key:对外只回键名
|
|
57
|
+
return maskMcpServers(j?.mcpServers ?? {});
|
|
58
|
+
}
|
|
59
|
+
async addMcp(name, spec) {
|
|
60
|
+
if (!isValidName(name))
|
|
61
|
+
throw new RpcException("bad_params", `invalid mcp name: ${name}`);
|
|
62
|
+
const j = (await readJson(this.mcpPath())) ?? {};
|
|
63
|
+
const servers = j.mcpServers ?? {};
|
|
64
|
+
// 掩码项沿用旧值,避免把 *** 写进配置
|
|
65
|
+
servers[name] = mergeMcpSecrets(spec, servers[name]);
|
|
66
|
+
j.mcpServers = servers;
|
|
67
|
+
await writeJsonAtomic(this.mcpPath(), j);
|
|
68
|
+
}
|
|
69
|
+
async removeMcp(name) {
|
|
70
|
+
const j = await readJson(this.mcpPath());
|
|
71
|
+
const servers = j?.mcpServers;
|
|
72
|
+
if (!servers || !(name in servers))
|
|
73
|
+
return;
|
|
74
|
+
delete servers[name];
|
|
75
|
+
await writeJsonAtomic(this.mcpPath(), j);
|
|
76
|
+
}
|
|
77
|
+
async getMemory(scope, cwd) {
|
|
78
|
+
const path = this.memoryPath(scope, cwd);
|
|
79
|
+
let content = "";
|
|
80
|
+
try {
|
|
81
|
+
content = await readFile(path, "utf8");
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// 文件不存在按空内容
|
|
85
|
+
}
|
|
86
|
+
return { path, content };
|
|
87
|
+
}
|
|
88
|
+
async putMemory(scope, content, cwd) {
|
|
89
|
+
const path = this.memoryPath(scope, cwd);
|
|
90
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
91
|
+
await writeFile(path, content);
|
|
92
|
+
return { path };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
/** 读 JSON 文件;不存在/解析失败返回 null */
|
|
5
|
+
export async function readJson(path) {
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** 原子写 JSON:先写临时文件再 rename,避免半截文件损坏配置 */
|
|
14
|
+
export async function writeJsonAtomic(path, data) {
|
|
15
|
+
await mkdir(dirname(path), { recursive: true });
|
|
16
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
17
|
+
await writeFile(tmp, JSON.stringify(data, null, 2) + "\n");
|
|
18
|
+
await rename(tmp, path);
|
|
19
|
+
}
|
|
20
|
+
/** 名称白名单(防路径穿越):小写字母/数字/中划线/下划线/点 */
|
|
21
|
+
export const isValidName = (name) => /^[a-z0-9][a-z0-9._-]{0,63}$/i.test(name) && !name.includes("..");
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** daemon 日志环形缓冲:tee console 输出,供 log:tail RPC 远程查看 */
|
|
2
|
+
import { appendFileSync } from "node:fs";
|
|
3
|
+
export class LogBuffer {
|
|
4
|
+
capacity;
|
|
5
|
+
lines = [];
|
|
6
|
+
constructor(capacity = 500) {
|
|
7
|
+
this.capacity = capacity;
|
|
8
|
+
}
|
|
9
|
+
write(line) {
|
|
10
|
+
this.lines.push(line);
|
|
11
|
+
if (this.lines.length > this.capacity) {
|
|
12
|
+
this.lines.splice(0, this.lines.length - this.capacity);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
tail(limit = 100) {
|
|
16
|
+
return this.lines.slice(-Math.max(1, Math.min(limit, this.capacity)));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 接管 console.log/warn/error:原样输出 + 落缓冲;logPath 存在且非后台派生
|
|
20
|
+
* (SC_DAEMON_LOG_VIA_STDIO 未置位)时同时 append 到日志文件——后台模式 stdio
|
|
21
|
+
* 已重定向到同一文件,env 门控避免双写;前台(TTY/管道)统一落盘。
|
|
22
|
+
*/
|
|
23
|
+
instrumentConsole(logPath) {
|
|
24
|
+
const toFile = logPath && !process.env.SC_DAEMON_LOG_VIA_STDIO ? (line) => {
|
|
25
|
+
try {
|
|
26
|
+
appendFileSync(logPath, line + "\n");
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* 日志文件不可写不影响运行 */
|
|
30
|
+
}
|
|
31
|
+
} : undefined;
|
|
32
|
+
const fmt = (args) => args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ");
|
|
33
|
+
for (const level of ["log", "warn", "error"]) {
|
|
34
|
+
const orig = console[level].bind(console);
|
|
35
|
+
console[level] = (...args) => {
|
|
36
|
+
const line = fmt(args);
|
|
37
|
+
this.write(line);
|
|
38
|
+
toFile?.(line);
|
|
39
|
+
orig(...args);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const MCP_SECRET_MASK = "***";
|
|
2
|
+
const maskRecord = (record) => record ? Object.fromEntries(Object.keys(record).map((key) => [key, MCP_SECRET_MASK])) : undefined;
|
|
3
|
+
/** 单个 server 的对外视图:env/headers 只保留键名 */
|
|
4
|
+
export function maskMcpSpec(spec) {
|
|
5
|
+
const env = maskRecord(spec.env);
|
|
6
|
+
const headers = maskRecord(spec.headers);
|
|
7
|
+
return {
|
|
8
|
+
...spec,
|
|
9
|
+
...(env ? { env } : {}),
|
|
10
|
+
...(headers ? { headers } : {}),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** 整表脱敏(mcp:list 回包) */
|
|
14
|
+
export function maskMcpServers(servers) {
|
|
15
|
+
return Object.fromEntries(Object.entries(servers).map(([name, spec]) => [name, maskMcpSpec(spec)]));
|
|
16
|
+
}
|
|
17
|
+
/** 掩码项回填旧值:新值不是掩码就按新值写(支持真正改密钥) */
|
|
18
|
+
function mergeRecord(next, prev) {
|
|
19
|
+
if (!next)
|
|
20
|
+
return undefined;
|
|
21
|
+
const merged = {};
|
|
22
|
+
for (const [key, value] of Object.entries(next)) {
|
|
23
|
+
merged[key] = value === MCP_SECRET_MASK ? (prev?.[key] ?? value) : value;
|
|
24
|
+
}
|
|
25
|
+
return merged;
|
|
26
|
+
}
|
|
27
|
+
/** 写回前还原被脱敏的密钥(mcp:add 的 spec 可能来自 list 的编辑结果) */
|
|
28
|
+
export function mergeMcpSecrets(next, prev) {
|
|
29
|
+
const env = mergeRecord(next.env, prev?.env);
|
|
30
|
+
const headers = mergeRecord(next.headers, prev?.headers);
|
|
31
|
+
return {
|
|
32
|
+
...next,
|
|
33
|
+
...(env ? { env } : {}),
|
|
34
|
+
...(headers ? { headers } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { mkdirSync } from "node:fs";
|
|
5
|
+
const DEFAULT_DB = join(homedir(), ".super-coding", "runs.db");
|
|
6
|
+
/**
|
|
7
|
+
* daemon 本地持久化:任务与事件流。
|
|
8
|
+
* seq 按 run 单调递增(从 1 开始)——server 断线重连后按 (run_id, last_seq) 增量补发。
|
|
9
|
+
*/
|
|
10
|
+
export class RunStore {
|
|
11
|
+
db;
|
|
12
|
+
constructor(dbPath) {
|
|
13
|
+
const path = dbPath ?? DEFAULT_DB;
|
|
14
|
+
if (path !== ":memory:") {
|
|
15
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
this.db = new DatabaseSync(path);
|
|
18
|
+
this.db.exec(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS runs(
|
|
20
|
+
run_id TEXT PRIMARY KEY,
|
|
21
|
+
agent TEXT,
|
|
22
|
+
cwd TEXT,
|
|
23
|
+
status TEXT DEFAULT 'running',
|
|
24
|
+
native_session_id TEXT,
|
|
25
|
+
createdAt INTEGER,
|
|
26
|
+
server_seq INTEGER NOT NULL DEFAULT 0
|
|
27
|
+
);
|
|
28
|
+
CREATE TABLE IF NOT EXISTS events(
|
|
29
|
+
run_id TEXT,
|
|
30
|
+
seq INTEGER,
|
|
31
|
+
event TEXT,
|
|
32
|
+
PRIMARY KEY(run_id, seq)
|
|
33
|
+
);
|
|
34
|
+
`);
|
|
35
|
+
// 旧 runs.db 原地升级:新增列都要幂等补齐(老库没有这些列时 SELECT 会直接报错)
|
|
36
|
+
const columns = this.db.prepare("PRAGMA table_info(runs)").all();
|
|
37
|
+
const has = (name) => columns.some((c) => c.name === name);
|
|
38
|
+
if (!has("server_seq")) {
|
|
39
|
+
this.db.exec("ALTER TABLE runs ADD COLUMN server_seq INTEGER NOT NULL DEFAULT 0");
|
|
40
|
+
}
|
|
41
|
+
if (!has("business_agent_id")) {
|
|
42
|
+
this.db.exec("ALTER TABLE runs ADD COLUMN business_agent_id TEXT");
|
|
43
|
+
}
|
|
44
|
+
if (!has("agent_slug")) {
|
|
45
|
+
this.db.exec("ALTER TABLE runs ADD COLUMN agent_slug TEXT");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
createRun(t) {
|
|
49
|
+
this.db
|
|
50
|
+
.prepare("INSERT INTO runs(run_id, agent, cwd, native_session_id, business_agent_id, agent_slug, createdAt)"
|
|
51
|
+
+ " VALUES(?,?,?,?,?,?,?)")
|
|
52
|
+
.run(t.run_id, t.agent, t.cwd, t.native_session_id ?? null, t.business_agent_id ?? null, t.agent_slug ?? null, Date.now());
|
|
53
|
+
}
|
|
54
|
+
/** 在事务内分配下一个 seq 并写入事件,返回分配的 seq */
|
|
55
|
+
appendEvent(run_id, event) {
|
|
56
|
+
this.db.exec("BEGIN");
|
|
57
|
+
try {
|
|
58
|
+
const row = this.db
|
|
59
|
+
.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS seq FROM events WHERE run_id = ?")
|
|
60
|
+
.get(run_id);
|
|
61
|
+
this.db
|
|
62
|
+
.prepare("INSERT INTO events(run_id, seq, event) VALUES(?,?,?)")
|
|
63
|
+
.run(run_id, row.seq, JSON.stringify(event));
|
|
64
|
+
this.db.exec("COMMIT");
|
|
65
|
+
return row.seq;
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
this.db.exec("ROLLBACK");
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
eventsAfter(run_id, afterSeq) {
|
|
73
|
+
const rows = this.db
|
|
74
|
+
.prepare("SELECT seq, event FROM events WHERE run_id = ? AND seq > ? ORDER BY seq ASC")
|
|
75
|
+
.all(run_id, afterSeq);
|
|
76
|
+
return rows.map((r) => ({ seq: r.seq, event: JSON.parse(r.event) }));
|
|
77
|
+
}
|
|
78
|
+
/** 只以 CES 返回的水位确认为准,单调推进;发送成功不代表已持久化 */
|
|
79
|
+
acknowledge(run_id, lastSeq) {
|
|
80
|
+
if (!Number.isInteger(lastSeq) || lastSeq < 0)
|
|
81
|
+
return;
|
|
82
|
+
this.db.prepare("UPDATE runs SET server_seq = MAX(server_seq, ?) WHERE run_id = ?").run(lastSeq, run_id);
|
|
83
|
+
}
|
|
84
|
+
/** 本地有事件但 CES 尚未确认的全部运行,终态也包含在内 */
|
|
85
|
+
pendingSync() {
|
|
86
|
+
return this.db.prepare(`
|
|
87
|
+
WITH progress AS (
|
|
88
|
+
SELECT runs.run_id, runs.createdAt, runs.server_seq,
|
|
89
|
+
COALESCE(MAX(events.seq), 0) AS local_seq
|
|
90
|
+
FROM runs LEFT JOIN events ON events.run_id = runs.run_id
|
|
91
|
+
GROUP BY runs.run_id, runs.createdAt, runs.server_seq
|
|
92
|
+
)
|
|
93
|
+
SELECT run_id, local_seq, server_seq FROM progress
|
|
94
|
+
WHERE local_seq > server_seq
|
|
95
|
+
ORDER BY createdAt ASC, run_id ASC
|
|
96
|
+
`).all();
|
|
97
|
+
}
|
|
98
|
+
setStatus(run_id, status) {
|
|
99
|
+
this.db.prepare("UPDATE runs SET status = ? WHERE run_id = ?").run(status, run_id);
|
|
100
|
+
}
|
|
101
|
+
setNativeSessionId(run_id, id) {
|
|
102
|
+
this.db.prepare("UPDATE runs SET native_session_id = ? WHERE run_id = ?").run(id, run_id);
|
|
103
|
+
}
|
|
104
|
+
/** resume 续会话复用目录:按 (agent, 原生会话 id) 取最早任务的执行目录(防跨 agent 撞 id) */
|
|
105
|
+
findCwdByNativeSession(native_session_id, agent) {
|
|
106
|
+
const row = this.db
|
|
107
|
+
.prepare("SELECT cwd FROM runs WHERE native_session_id = ? AND agent = ? ORDER BY createdAt ASC, rowid ASC LIMIT 1")
|
|
108
|
+
.get(native_session_id, agent);
|
|
109
|
+
return row?.cwd ?? null;
|
|
110
|
+
}
|
|
111
|
+
/** 续聊校验:按 (agent, 原生会话 id) 取最早一条运行(用于 agent 绑定一致性检查) */
|
|
112
|
+
findRunByNativeSession(native_session_id, agent) {
|
|
113
|
+
const row = this.db
|
|
114
|
+
.prepare("SELECT run_id, agent, cwd, status, native_session_id, business_agent_id, agent_slug, createdAt, server_seq"
|
|
115
|
+
+ " FROM runs WHERE native_session_id = ? AND agent = ? ORDER BY createdAt ASC, rowid ASC LIMIT 1")
|
|
116
|
+
.get(native_session_id, agent);
|
|
117
|
+
return row ?? null;
|
|
118
|
+
}
|
|
119
|
+
getRun(run_id) {
|
|
120
|
+
const row = this.db
|
|
121
|
+
.prepare("SELECT run_id, agent, cwd, status, native_session_id, business_agent_id, agent_slug, createdAt, server_seq"
|
|
122
|
+
+ " FROM runs WHERE run_id = ?")
|
|
123
|
+
.get(run_id);
|
|
124
|
+
return row ?? null;
|
|
125
|
+
}
|
|
126
|
+
/** 全部任务(新→旧),可按 status 过滤 */
|
|
127
|
+
listRuns(status) {
|
|
128
|
+
const sql = "SELECT run_id, agent, cwd, status, native_session_id, business_agent_id, agent_slug, createdAt, server_seq FROM runs"
|
|
129
|
+
+ (status ? " WHERE status = ?" : "")
|
|
130
|
+
+ " ORDER BY createdAt DESC";
|
|
131
|
+
const rows = (status ? this.db.prepare(sql).all(status) : this.db.prepare(sql).all());
|
|
132
|
+
return rows;
|
|
133
|
+
}
|
|
134
|
+
close() {
|
|
135
|
+
this.db.close();
|
|
136
|
+
}
|
|
137
|
+
}
|