@shgroup/dsh-serenity-hooks 1.21.0 → 1.23.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/dsh.plugin.json +1 -1
- package/lib/client.js +832 -484
- package/lib/index.js +1617 -876
- package/package.json +2 -1
package/lib/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { homedir, platform } from "node:os";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
10
|
-
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
|
|
10
|
+
import { createHmac, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
|
|
11
11
|
import { createServer, request } from "node:http";
|
|
12
12
|
//#region src/ccc.ts
|
|
13
13
|
/**
|
|
@@ -213,10 +213,10 @@ function runCcFs(root, args) {
|
|
|
213
213
|
switch (a) {
|
|
214
214
|
case "root": return root;
|
|
215
215
|
case "resolve":
|
|
216
|
-
if (!args.path) throw new Error("resolve
|
|
216
|
+
if (!args.path) throw new Error("resolve requires path");
|
|
217
217
|
return resolveInside(root, args.path);
|
|
218
218
|
case "exists": {
|
|
219
|
-
if (!args.path) throw new Error("exists
|
|
219
|
+
if (!args.path) throw new Error("exists requires path");
|
|
220
220
|
const absPath = args.path.startsWith("/") ? resolve(args.path) : resolveInside(root, args.path);
|
|
221
221
|
return existsSync(absPath) ? "true" : "false";
|
|
222
222
|
}
|
|
@@ -271,13 +271,13 @@ function runCcFs(root, args) {
|
|
|
271
271
|
};
|
|
272
272
|
}
|
|
273
273
|
case "relative": {
|
|
274
|
-
if (!args.path) throw new Error("relative
|
|
274
|
+
if (!args.path) throw new Error("relative requires path");
|
|
275
275
|
const absPath = args.path.startsWith("/") ? resolve(args.path) : resolveInside(root, args.path);
|
|
276
276
|
if (!absPath.startsWith(root)) throw new Error(`relative: path "${args.path}" resolves to "${absPath}" which is outside serenity root "${root}"`);
|
|
277
277
|
return safeRel(root, absPath);
|
|
278
278
|
}
|
|
279
279
|
case "mkdir": {
|
|
280
|
-
if (!args.path) throw new Error("mkdir
|
|
280
|
+
if (!args.path) throw new Error("mkdir requires path");
|
|
281
281
|
const absPath = validateWritePath(root, args.path);
|
|
282
282
|
if (existsSync(absPath)) {
|
|
283
283
|
if (statSync(absPath).isDirectory()) return `directory already exists: ${args.path}`;
|
|
@@ -289,7 +289,7 @@ function runCcFs(root, args) {
|
|
|
289
289
|
case "rm": {
|
|
290
290
|
const targets = [...args.paths ?? []];
|
|
291
291
|
if (args.path) targets.push(args.path);
|
|
292
|
-
if (targets.length === 0) throw new Error("rm
|
|
292
|
+
if (targets.length === 0) throw new Error("rm requires at least one path argument (path or paths)");
|
|
293
293
|
const dryRun = args.dryRun ?? false;
|
|
294
294
|
const recursive = args.recursive ?? false;
|
|
295
295
|
const results = [];
|
|
@@ -334,7 +334,7 @@ function runCcFs(root, args) {
|
|
|
334
334
|
return results.join("\n");
|
|
335
335
|
}
|
|
336
336
|
case "mv": {
|
|
337
|
-
if (!args.src || !args.dst) throw new Error("mv
|
|
337
|
+
if (!args.src || !args.dst) throw new Error("mv requires src + dst");
|
|
338
338
|
const srcAbs = validateWritePath(root, args.src);
|
|
339
339
|
const dstAbs = validateWritePath(root, args.dst);
|
|
340
340
|
if (!existsSync(srcAbs)) throw new Error(`mv: source not found: ${args.src}`);
|
|
@@ -345,7 +345,7 @@ function runCcFs(root, args) {
|
|
|
345
345
|
return `moved: ${args.src} → ${args.dst}`;
|
|
346
346
|
}
|
|
347
347
|
case "cp": {
|
|
348
|
-
if (!args.src || !args.dst) throw new Error("cp
|
|
348
|
+
if (!args.src || !args.dst) throw new Error("cp requires src + dst");
|
|
349
349
|
const srcAbs = validateWritePath(root, args.src);
|
|
350
350
|
const dstAbs = validateWritePath(root, args.dst);
|
|
351
351
|
if (!existsSync(srcAbs)) throw new Error(`cp: source not found: ${args.src}`);
|
|
@@ -357,7 +357,7 @@ function runCcFs(root, args) {
|
|
|
357
357
|
return `copied: ${args.src} → ${args.dst}`;
|
|
358
358
|
}
|
|
359
359
|
case "touch": {
|
|
360
|
-
if (!args.path) throw new Error("touch
|
|
360
|
+
if (!args.path) throw new Error("touch requires path");
|
|
361
361
|
const absPath = validateWritePath(root, args.path);
|
|
362
362
|
if (existsSync(absPath)) {
|
|
363
363
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -370,7 +370,7 @@ function runCcFs(root, args) {
|
|
|
370
370
|
return `created empty file: ${args.path}`;
|
|
371
371
|
}
|
|
372
372
|
case "append": {
|
|
373
|
-
if (!args.path || args.content === void 0) throw new Error("append
|
|
373
|
+
if (!args.path || args.content === void 0) throw new Error("append requires path + content");
|
|
374
374
|
const absPath = validateWritePath(root, args.path);
|
|
375
375
|
const parentDir = dirname(absPath);
|
|
376
376
|
if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
|
|
@@ -379,7 +379,7 @@ function runCcFs(root, args) {
|
|
|
379
379
|
return `appended ${Buffer.byteLength(content, "utf8")} bytes to ${args.path}`;
|
|
380
380
|
}
|
|
381
381
|
case "reveal": {
|
|
382
|
-
if (!args.path) throw new Error("reveal
|
|
382
|
+
if (!args.path) throw new Error("reveal requires path");
|
|
383
383
|
const absPath = resolveInside(root, args.path);
|
|
384
384
|
if (!existsSync(absPath)) throw new Error(`no such path: ${absPath}`);
|
|
385
385
|
const os = platform();
|
|
@@ -405,7 +405,7 @@ function runCcFs(root, args) {
|
|
|
405
405
|
}
|
|
406
406
|
}
|
|
407
407
|
case "info": {
|
|
408
|
-
if (!args.path) throw new Error("info
|
|
408
|
+
if (!args.path) throw new Error("info requires path");
|
|
409
409
|
const absPath = args.path.startsWith("/") ? resolve(args.path) : resolveInside(root, args.path);
|
|
410
410
|
if (!existsSync(absPath)) throw new Error(`info: path "${absPath}" does not exist`);
|
|
411
411
|
const stat = statSync(absPath);
|
|
@@ -422,7 +422,7 @@ function runCcFs(root, args) {
|
|
|
422
422
|
].join("\n");
|
|
423
423
|
}
|
|
424
424
|
case "find": {
|
|
425
|
-
if (!args.pattern) throw new Error("find
|
|
425
|
+
if (!args.pattern) throw new Error("find requires pattern");
|
|
426
426
|
const relPath = args.path || ".";
|
|
427
427
|
const absPath = relPath.startsWith("/") ? resolve(relPath) : resolveInside(root, relPath);
|
|
428
428
|
if (!existsSync(absPath)) throw new Error(`find: path "${absPath}" does not exist`);
|
|
@@ -471,7 +471,7 @@ function runCcFs(root, args) {
|
|
|
471
471
|
count: matches.length
|
|
472
472
|
};
|
|
473
473
|
}
|
|
474
|
-
default: throw new Error(
|
|
474
|
+
default: throw new Error(`Unknown action: ${a}`);
|
|
475
475
|
}
|
|
476
476
|
}
|
|
477
477
|
//#endregion
|
|
@@ -493,66 +493,66 @@ function renderText$10(value) {
|
|
|
493
493
|
}
|
|
494
494
|
const ccFsTool = defineTool({
|
|
495
495
|
name: "cc_fs",
|
|
496
|
-
description: "CCC
|
|
496
|
+
description: "CCC filesystem operations (cc-fs semantics, DSH-native). 15 subcommands: root/resolve/exists/list/tree/relative/mkdir/rm/mv/cp/touch/append/reveal/info/find. All paths confined to the CCC root; path escape is blocked automatically. reveal opens a path in the OS file manager (Linux xdg-open / macOS Finder / Windows Explorer). Complements read/write/edit (structural operations).",
|
|
497
497
|
parameters: {
|
|
498
498
|
action: {
|
|
499
499
|
type: "string",
|
|
500
500
|
enum: [...CC_FS_ACTIONS],
|
|
501
501
|
required: true,
|
|
502
|
-
description: "
|
|
502
|
+
description: "Subcommand"
|
|
503
503
|
},
|
|
504
504
|
path: {
|
|
505
505
|
type: "string",
|
|
506
|
-
description: "
|
|
506
|
+
description: "Primary path argument (single-path ops: resolve/exists/list/tree/relative/touch/append/info/rm)"
|
|
507
507
|
},
|
|
508
508
|
paths: {
|
|
509
509
|
type: "array",
|
|
510
510
|
items: { type: "string" },
|
|
511
|
-
description: "
|
|
511
|
+
description: "Batch paths (mkdir/rm)"
|
|
512
512
|
},
|
|
513
513
|
src: {
|
|
514
514
|
type: "string",
|
|
515
|
-
description: "mv/cp
|
|
515
|
+
description: "mv/cp source path"
|
|
516
516
|
},
|
|
517
517
|
dst: {
|
|
518
518
|
type: "string",
|
|
519
|
-
description: "mv/cp
|
|
519
|
+
description: "mv/cp destination path"
|
|
520
520
|
},
|
|
521
521
|
content: {
|
|
522
522
|
type: "string",
|
|
523
|
-
description: "append
|
|
523
|
+
description: "append content"
|
|
524
524
|
},
|
|
525
525
|
pattern: {
|
|
526
526
|
type: "string",
|
|
527
|
-
description: "find
|
|
527
|
+
description: "find match (glob *? or case-insensitive substring)"
|
|
528
528
|
},
|
|
529
529
|
depth: {
|
|
530
530
|
type: "integer",
|
|
531
|
-
description: "tree
|
|
531
|
+
description: "tree max depth (1-10, default 3)"
|
|
532
532
|
},
|
|
533
533
|
dryRun: {
|
|
534
534
|
type: "boolean",
|
|
535
|
-
description: "rm/archive
|
|
535
|
+
description: "rm/archive preview mode"
|
|
536
536
|
},
|
|
537
537
|
recursive: {
|
|
538
538
|
type: "boolean",
|
|
539
|
-
description: "rm
|
|
539
|
+
description: "rm directory / cp directory requires recursive"
|
|
540
540
|
},
|
|
541
541
|
filesOnly: {
|
|
542
542
|
type: "boolean",
|
|
543
|
-
description: "tree
|
|
543
|
+
description: "tree shows files only (mutually exclusive with dirsOnly)"
|
|
544
544
|
},
|
|
545
545
|
dirsOnly: {
|
|
546
546
|
type: "boolean",
|
|
547
|
-
description: "tree
|
|
547
|
+
description: "tree shows directories only (mutually exclusive with filesOnly)"
|
|
548
548
|
},
|
|
549
549
|
absolute: {
|
|
550
550
|
type: "boolean",
|
|
551
|
-
description: "find
|
|
551
|
+
description: "find returns absolute paths"
|
|
552
552
|
},
|
|
553
553
|
maxDepth: {
|
|
554
554
|
type: "integer",
|
|
555
|
-
description: "find
|
|
555
|
+
description: "find max recursion depth (default unlimited)"
|
|
556
556
|
}
|
|
557
557
|
},
|
|
558
558
|
output: {
|
|
@@ -625,24 +625,24 @@ function isWriteTool(toolName, action) {
|
|
|
625
625
|
function decideGuard(input) {
|
|
626
626
|
const { root, toolName, safeModeOn, blacklist, pathArg, action } = input;
|
|
627
627
|
if (safeModeOn && toolName === "bash") return {
|
|
628
|
-
deny: `bash:
|
|
628
|
+
deny: `bash: no such tool`,
|
|
629
629
|
kind: "deny"
|
|
630
630
|
};
|
|
631
631
|
if (pathArg !== void 0) {
|
|
632
632
|
const abs = resolve(root, pathArg);
|
|
633
633
|
if (!pathInside(resolve(root), abs)) return {
|
|
634
|
-
deny: `path escape blocked: "${pathArg}"
|
|
634
|
+
deny: `path escape blocked: "${pathArg}" escapes the CCC root`,
|
|
635
635
|
kind: "deny"
|
|
636
636
|
};
|
|
637
637
|
if (isWriteTool(toolName, action)) {
|
|
638
638
|
const rel = relative(root, abs).split("\\").join("/");
|
|
639
639
|
if (rel === ".serenity-safe-on" || rel === ".serenity" || rel.startsWith(".serenity-safe-on/") || rel.startsWith(".serenity/")) return {
|
|
640
|
-
deny: `CCC
|
|
640
|
+
deny: `CCC governance file "${rel}" is reserved for the user — agent must not write`,
|
|
641
641
|
kind: "deny"
|
|
642
642
|
};
|
|
643
643
|
const hit = matchBlacklist(rel, blacklist);
|
|
644
644
|
if (hit) return {
|
|
645
|
-
deny: hit.message ?? `blacklist blocked: "${pathArg}"
|
|
645
|
+
deny: hit.message ?? `blacklist blocked: "${pathArg}" matches rule "${hit.pattern}"`,
|
|
646
646
|
kind: "deny"
|
|
647
647
|
};
|
|
648
648
|
}
|
|
@@ -916,11 +916,11 @@ async function runKit(root, args) {
|
|
|
916
916
|
}
|
|
917
917
|
case "wait": {
|
|
918
918
|
const seconds = args.seconds ?? 1;
|
|
919
|
-
if (!Number.isInteger(seconds) || seconds <= 0) throw new Error("wait
|
|
919
|
+
if (!Number.isInteger(seconds) || seconds <= 0) throw new Error("wait requires a positive integer number of seconds (default 1)");
|
|
920
920
|
await new Promise((r) => setTimeout(r, seconds * 1e3));
|
|
921
921
|
return `waited ${seconds}s`;
|
|
922
922
|
}
|
|
923
|
-
default: throw new Error(
|
|
923
|
+
default: throw new Error(`Unknown action: ${args.action}`);
|
|
924
924
|
}
|
|
925
925
|
}
|
|
926
926
|
//#endregion
|
|
@@ -939,17 +939,17 @@ function renderText$9(value) {
|
|
|
939
939
|
}
|
|
940
940
|
const kitTool = defineTool({
|
|
941
941
|
name: "acc_kit",
|
|
942
|
-
description: "ACC
|
|
942
|
+
description: "ACC general-purpose utility kit: health (CCC three-principle check P1/P2/config, healthy/degraded report) / time (now_iso/now_local/epoch_ms) / wait (wait N seconds, default 1). Routine self-check before entering a CCC.",
|
|
943
943
|
parameters: {
|
|
944
944
|
action: {
|
|
945
945
|
type: "string",
|
|
946
946
|
enum: [...KIT_ACTIONS],
|
|
947
947
|
required: true,
|
|
948
|
-
description: "
|
|
948
|
+
description: "Subcommand"
|
|
949
949
|
},
|
|
950
950
|
seconds: {
|
|
951
951
|
type: "integer",
|
|
952
|
-
description: "wait
|
|
952
|
+
description: "Seconds to wait (positive integer, default 1)"
|
|
953
953
|
}
|
|
954
954
|
},
|
|
955
955
|
output: {
|
|
@@ -1018,7 +1018,7 @@ function ensureLocalstoreGitignored(root) {
|
|
|
1018
1018
|
if (isLocalstoreGitignored(root)) return { status: "ignored" };
|
|
1019
1019
|
const gi = join(root, ".gitignore");
|
|
1020
1020
|
const existing = existsSync(gi) ? readFileSync(gi, "utf-8") : "";
|
|
1021
|
-
const line = `${LOCALSTORE_FILENAME} # ACC localstore
|
|
1021
|
+
const line = `${LOCALSTORE_FILENAME} # ACC localstore (localstore.gitTrack defaults to deny — do not commit)`;
|
|
1022
1022
|
writeFileSync(gi, (existing.endsWith("\n") || existing === "" ? existing : existing + "\n") + line + "\n", "utf-8");
|
|
1023
1023
|
return { status: "appended" };
|
|
1024
1024
|
}
|
|
@@ -1032,11 +1032,11 @@ function checkLocalstoreGitCompliance(root) {
|
|
|
1032
1032
|
if (isLocalstoreGitignored(root)) return { ok: true };
|
|
1033
1033
|
return {
|
|
1034
1034
|
ok: false,
|
|
1035
|
-
reason: `localstore.json
|
|
1035
|
+
reason: `localstore.json must not be committed (localstore.gitTrack=deny default) but .gitignore does not cover it — add ${LOCALSTORE_FILENAME} to .gitignore (or set localstore.gitTrack=allow to explicitly permit)`
|
|
1036
1036
|
};
|
|
1037
1037
|
}
|
|
1038
1038
|
/** 读取全文件(顶层分节);文件不存在/坏 JSON 返回空 */
|
|
1039
|
-
function readAll
|
|
1039
|
+
function readAll(root) {
|
|
1040
1040
|
const p = localstorePath(root);
|
|
1041
1041
|
if (!existsSync(p)) return {};
|
|
1042
1042
|
try {
|
|
@@ -1048,28 +1048,28 @@ function readAll$1(root) {
|
|
|
1048
1048
|
}
|
|
1049
1049
|
}
|
|
1050
1050
|
/** 写回全文件(2 空格缩进 + 尾换行,方便 MSM 直接读取) */
|
|
1051
|
-
function writeAll
|
|
1051
|
+
function writeAll(root, data) {
|
|
1052
1052
|
writeFileSync(localstorePath(root), JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
1053
1053
|
}
|
|
1054
1054
|
/** 读取命名空间全部条目:credential → 扁平;config → 分节(剔除 credentials 保留节) */
|
|
1055
1055
|
function readStore(root, scope) {
|
|
1056
|
-
const all = readAll
|
|
1056
|
+
const all = readAll(root);
|
|
1057
1057
|
if (scope === "credential") return all["credentials"] ?? {};
|
|
1058
1058
|
const { [CREDENTIALS_SECTION]: _cred, ...rest } = all;
|
|
1059
1059
|
return rest;
|
|
1060
1060
|
}
|
|
1061
1061
|
/** 校验凭据 key 合法(大写蛇形);不合法抛错 */
|
|
1062
1062
|
function assertCredentialKey(key) {
|
|
1063
|
-
if (!CREDENTIAL_KEY_RE.test(key)) throw new Error(`credential key "${key}"
|
|
1063
|
+
if (!CREDENTIAL_KEY_RE.test(key)) throw new Error(`credential key "${key}" must match UPPER_SNAKE ^[A-Z][A-Z0-9_]*$ (e.g. HOME_GITLAB_TOKEN)`);
|
|
1064
1064
|
}
|
|
1065
1065
|
/** 校验 config 路径(节.key);不合法抛错 */
|
|
1066
1066
|
function assertConfigPath(path) {
|
|
1067
1067
|
const idx = path.indexOf(".");
|
|
1068
|
-
if (idx <= 0 || idx === path.length - 1) throw new Error(`config path "${path}"
|
|
1068
|
+
if (idx <= 0 || idx === path.length - 1) throw new Error(`config path "${path}" must be section.key (e.g. loop.defaultModel)`);
|
|
1069
1069
|
const section = path.slice(0, idx);
|
|
1070
1070
|
const key = path.slice(idx + 1);
|
|
1071
|
-
if (!CONFIG_SECTION_RE.test(section)) throw new Error(`config
|
|
1072
|
-
if (!CONFIG_KEY_RE.test(key)) throw new Error(`config key "${key}"
|
|
1071
|
+
if (!CONFIG_SECTION_RE.test(section)) throw new Error(`config section "${section}" must match ^[a-z][a-z0-9-]*$`);
|
|
1072
|
+
if (!CONFIG_KEY_RE.test(key)) throw new Error(`config key "${key}" must match lowerCamel ^[a-z][a-zA-Z0-9_]*$ (e.g. defaultModel)`);
|
|
1073
1073
|
return {
|
|
1074
1074
|
section,
|
|
1075
1075
|
key
|
|
@@ -1077,7 +1077,7 @@ function assertConfigPath(path) {
|
|
|
1077
1077
|
}
|
|
1078
1078
|
/** 写入单个条目(自动建文件;deny 默认时同步确保 .gitignore 物理保证) */
|
|
1079
1079
|
function writeEntry(root, scope, name, value) {
|
|
1080
|
-
const all = readAll
|
|
1080
|
+
const all = readAll(root);
|
|
1081
1081
|
if (scope === "credential") {
|
|
1082
1082
|
assertCredentialKey(name);
|
|
1083
1083
|
all[CREDENTIALS_SECTION] ??= {};
|
|
@@ -1087,12 +1087,12 @@ function writeEntry(root, scope, name, value) {
|
|
|
1087
1087
|
all[section] ??= {};
|
|
1088
1088
|
all[section][key] = value;
|
|
1089
1089
|
}
|
|
1090
|
-
writeAll
|
|
1090
|
+
writeAll(root, all);
|
|
1091
1091
|
ensureLocalstoreGitignored(root);
|
|
1092
1092
|
}
|
|
1093
1093
|
/** 删除单个条目(不存在返回 false);空节自动移除 */
|
|
1094
1094
|
function unsetEntry(root, scope, name) {
|
|
1095
|
-
const all = readAll
|
|
1095
|
+
const all = readAll(root);
|
|
1096
1096
|
if (scope === "credential") {
|
|
1097
1097
|
assertCredentialKey(name);
|
|
1098
1098
|
const sec = all[CREDENTIALS_SECTION];
|
|
@@ -1106,12 +1106,12 @@ function unsetEntry(root, scope, name) {
|
|
|
1106
1106
|
delete sec[key];
|
|
1107
1107
|
if (Object.keys(sec).length === 0) delete all[section];
|
|
1108
1108
|
}
|
|
1109
|
-
writeAll
|
|
1109
|
+
writeAll(root, all);
|
|
1110
1110
|
return true;
|
|
1111
1111
|
}
|
|
1112
1112
|
/** 读取单个条目值;不存在返回 null */
|
|
1113
1113
|
function getEntry(root, scope, name) {
|
|
1114
|
-
const all = readAll
|
|
1114
|
+
const all = readAll(root);
|
|
1115
1115
|
if (scope === "credential") {
|
|
1116
1116
|
assertCredentialKey(name);
|
|
1117
1117
|
return all["credentials"]?.[name] ?? null;
|
|
@@ -1132,18 +1132,18 @@ function listKeys(root, scope) {
|
|
|
1132
1132
|
function docText(root) {
|
|
1133
1133
|
const path = localstorePath(root);
|
|
1134
1134
|
return [
|
|
1135
|
-
"# localstore — ACC
|
|
1135
|
+
"# localstore — ACC local credential/config storage (standard, S134 redesign)",
|
|
1136
1136
|
"",
|
|
1137
|
-
"
|
|
1138
|
-
|
|
1137
|
+
"One tool manages two namespaces: credential (credentials) + config (preferences).",
|
|
1138
|
+
`Stored at the CCC root in ${path} (JSON format; MSMs can read + JSON.parse directly).`,
|
|
1139
1139
|
"",
|
|
1140
|
-
"##
|
|
1141
|
-
"-
|
|
1142
|
-
"-
|
|
1143
|
-
"
|
|
1144
|
-
"-
|
|
1140
|
+
"## Git commit policy",
|
|
1141
|
+
"- Config: .opencode/serenity.json `localstore.gitTrack`: `\"allow\"` (may commit) | `\"deny\"` (must not commit; .dsh fallback)",
|
|
1142
|
+
"- **Default deny (unset = not committed)**; when deny, writes automatically ensure .gitignore contains localstore.json",
|
|
1143
|
+
" (physical guarantee, independent of dsh runtime); cc_git commit checks and refuses",
|
|
1144
|
+
"- To commit: set `\"localstore\": { \"gitTrack\": \"allow\" }` and remove localstore.json from .gitignore",
|
|
1145
1145
|
"",
|
|
1146
|
-
"##
|
|
1146
|
+
"## Format (JSON top-level sections)",
|
|
1147
1147
|
"```json",
|
|
1148
1148
|
"{",
|
|
1149
1149
|
" \"credentials\": {",
|
|
@@ -1154,24 +1154,24 @@ function docText(root) {
|
|
|
1154
1154
|
" }",
|
|
1155
1155
|
"}",
|
|
1156
1156
|
"```",
|
|
1157
|
-
"- credentials
|
|
1157
|
+
"- credentials is a reserved section (credentials, UPPER_SNAKE keys); other sections = config (path = section.key)",
|
|
1158
1158
|
"",
|
|
1159
|
-
"##
|
|
1160
|
-
"- credential key: ^[A-Z][A-Z0-9_]
|
|
1161
|
-
"- config path: section.key
|
|
1159
|
+
"## Key conventions",
|
|
1160
|
+
"- credential key: ^[A-Z][A-Z0-9_]*$ (e.g. HOME_GITLAB_TOKEN)",
|
|
1161
|
+
"- config path: section.key (section ^[a-z][a-z0-9-]*$, key lowerCamel ^[a-z][a-zA-Z0-9_]*$, e.g. loop.defaultModel)",
|
|
1162
1162
|
"",
|
|
1163
|
-
"##
|
|
1164
|
-
`-
|
|
1165
|
-
"-
|
|
1163
|
+
"## Reading (agents may use fs tools directly)",
|
|
1164
|
+
`- Read ${path} with the read tool → JSON.parse`,
|
|
1165
|
+
"- Or use localstore get <name> [--scope credential|config]",
|
|
1166
1166
|
"",
|
|
1167
|
-
"##
|
|
1168
|
-
"-
|
|
1169
|
-
"-
|
|
1167
|
+
"## Writing",
|
|
1168
|
+
"- Recommended: localstore set <name> <value> [--scope ...] (auto-creates file / preserves other entries / syncs .gitignore)",
|
|
1169
|
+
"- Or modify the file directly with write/edit, keeping the JSON valid",
|
|
1170
1170
|
"",
|
|
1171
|
-
"##
|
|
1172
|
-
"- list/show
|
|
1173
|
-
"-
|
|
1174
|
-
"-
|
|
1171
|
+
"## Security boundary",
|
|
1172
|
+
"- list/show return only key names for credentials, never values",
|
|
1173
|
+
"- Credential values should be used internally by the agent; never write them into conversation or logs",
|
|
1174
|
+
"- Default deny: the file is not committed to git (.gitignore physical guarantee + cc_git check fallback)",
|
|
1175
1175
|
""
|
|
1176
1176
|
].join("\n");
|
|
1177
1177
|
}
|
|
@@ -1184,9 +1184,9 @@ function runLocalStore(root, args) {
|
|
|
1184
1184
|
keys: listKeys(root, scope)
|
|
1185
1185
|
};
|
|
1186
1186
|
case "get": {
|
|
1187
|
-
if (!args.name) throw new Error("get
|
|
1187
|
+
if (!args.name) throw new Error("get requires name");
|
|
1188
1188
|
const value = getEntry(root, scope, args.name);
|
|
1189
|
-
if (value === null) throw new Error(`not found: ${args.name}
|
|
1189
|
+
if (value === null) throw new Error(`not found: ${args.name} (scope=${scope})`);
|
|
1190
1190
|
return {
|
|
1191
1191
|
scope,
|
|
1192
1192
|
name: args.name,
|
|
@@ -1195,8 +1195,8 @@ function runLocalStore(root, args) {
|
|
|
1195
1195
|
};
|
|
1196
1196
|
}
|
|
1197
1197
|
case "set": {
|
|
1198
|
-
if (!args.name) throw new Error("set
|
|
1199
|
-
if (args.value === void 0) throw new Error("set
|
|
1198
|
+
if (!args.name) throw new Error("set requires name");
|
|
1199
|
+
if (args.value === void 0) throw new Error("set requires value");
|
|
1200
1200
|
writeEntry(root, scope, args.name, args.value);
|
|
1201
1201
|
const git = checkLocalstoreGitCompliance(root);
|
|
1202
1202
|
return {
|
|
@@ -1210,7 +1210,7 @@ function runLocalStore(root, args) {
|
|
|
1210
1210
|
};
|
|
1211
1211
|
}
|
|
1212
1212
|
case "unset": {
|
|
1213
|
-
if (!args.name) throw new Error("unset
|
|
1213
|
+
if (!args.name) throw new Error("unset requires name");
|
|
1214
1214
|
const removed = unsetEntry(root, scope, args.name);
|
|
1215
1215
|
return {
|
|
1216
1216
|
scope,
|
|
@@ -1219,7 +1219,7 @@ function runLocalStore(root, args) {
|
|
|
1219
1219
|
};
|
|
1220
1220
|
}
|
|
1221
1221
|
case "show": {
|
|
1222
|
-
if (!args.name) throw new Error("show
|
|
1222
|
+
if (!args.name) throw new Error("show requires name");
|
|
1223
1223
|
const exists = getEntry(root, scope, args.name) !== null;
|
|
1224
1224
|
return {
|
|
1225
1225
|
scope,
|
|
@@ -1229,7 +1229,7 @@ function runLocalStore(root, args) {
|
|
|
1229
1229
|
};
|
|
1230
1230
|
}
|
|
1231
1231
|
case "doc": return { doc: docText(root) };
|
|
1232
|
-
default: throw new Error(
|
|
1232
|
+
default: throw new Error(`Unknown subcommand: ${args.action} (available: list/get/set/unset/show/doc)`);
|
|
1233
1233
|
}
|
|
1234
1234
|
}
|
|
1235
1235
|
//#endregion
|
|
@@ -1276,7 +1276,7 @@ function git(root, args) {
|
|
|
1276
1276
|
} catch (err) {
|
|
1277
1277
|
return {
|
|
1278
1278
|
stdout: (err.stdout?.toString() ?? "").trimEnd(),
|
|
1279
|
-
stderr: err.killed ? `git
|
|
1279
|
+
stderr: err.killed ? `git operation timed out (${GIT_TIMEOUT_MS / 1e3}s)` : (err.stderr?.toString() ?? "").trimEnd()
|
|
1280
1280
|
};
|
|
1281
1281
|
}
|
|
1282
1282
|
}
|
|
@@ -1306,7 +1306,7 @@ function runGit(root, args) {
|
|
|
1306
1306
|
"status",
|
|
1307
1307
|
"--porcelain"
|
|
1308
1308
|
]);
|
|
1309
|
-
if (r.stderr) throw new Error(`status
|
|
1309
|
+
if (r.stderr) throw new Error(`status failed: ${r.stderr.trim()}`);
|
|
1310
1310
|
const files = (r.stdout ? r.stdout.split("\n") : []).map((line) => {
|
|
1311
1311
|
return {
|
|
1312
1312
|
status: line.slice(0, 2).trim() || "??",
|
|
@@ -1353,7 +1353,7 @@ function runGit(root, args) {
|
|
|
1353
1353
|
"origin",
|
|
1354
1354
|
branch
|
|
1355
1355
|
]);
|
|
1356
|
-
if (stderr.includes("non-fast-forward") || stderr.includes("rejected") || stderr.includes("[rejected]")) return `[REJECTED] Push to origin/${branch} was rejected (non-fast-forward).\n\n
|
|
1356
|
+
if (stderr.includes("non-fast-forward") || stderr.includes("rejected") || stderr.includes("[rejected]")) return `[REJECTED] Push to origin/${branch} was rejected (non-fast-forward).\n\nRemote has new commits and your local branch is behind. Suggested actions:\n 1. Use bash: git fetch origin ${branch}\n 2. Inspect remote changes: git log HEAD..origin/${branch}\n 3. Merge or rebase: git merge origin/${branch} or git rebase origin/${branch}\n 4. Resolve conflicts manually if any: git add ... && git commit\n 5. Push again: cc_git push`;
|
|
1357
1357
|
if (!stderr || stderr.includes("->") || stderr === "") return stdout || `Pushed to origin/${branch}`;
|
|
1358
1358
|
throw new Error(`cc_git push failed:\n${stderr}`);
|
|
1359
1359
|
}
|
|
@@ -1382,7 +1382,7 @@ function runGit(root, args) {
|
|
|
1382
1382
|
const msg = mergeResult.stdout || "Pulled successfully.";
|
|
1383
1383
|
return msg.endsWith("\n") ? msg.trimEnd() : msg;
|
|
1384
1384
|
}
|
|
1385
|
-
if (mergeResult.stderr.includes("non-fast-forward") || mergeResult.stderr.includes("Not possible to fast-forward") || mergeResult.stderr.includes("rejected") || mergeResult.stderr.includes("could not be applied")) return `[REJECTED] Pull from origin/${branch} was rejected (non-fast-forward).\n\n
|
|
1385
|
+
if (mergeResult.stderr.includes("non-fast-forward") || mergeResult.stderr.includes("Not possible to fast-forward") || mergeResult.stderr.includes("rejected") || mergeResult.stderr.includes("could not be applied")) return `[REJECTED] Pull from origin/${branch} was rejected (non-fast-forward).\n\nRemote has new commits and your local history diverged from remote (non-fast-forward). Suggested actions:\n 1. Inspect differences: cc_git log HEAD..origin/${branch}\n 2. Merge manually with bash: git merge origin/${branch}\n 3. Or rebase: git rebase origin/${branch}\n 4. Resolve conflicts manually if any: git add <file> && git commit\n 5. Push: cc_git push`;
|
|
1386
1386
|
throw new Error(`cc_git pull failed:\n${mergeResult.stderr}`);
|
|
1387
1387
|
}
|
|
1388
1388
|
case "log": {
|
|
@@ -1395,7 +1395,7 @@ function runGit(root, args) {
|
|
|
1395
1395
|
"-n",
|
|
1396
1396
|
String(n)
|
|
1397
1397
|
]);
|
|
1398
|
-
if (r.stderr) throw new Error(`git log
|
|
1398
|
+
if (r.stderr) throw new Error(`git log failed: ${r.stderr.trim()}`);
|
|
1399
1399
|
if (!r.stdout) return "(no commits)";
|
|
1400
1400
|
return r.stdout;
|
|
1401
1401
|
}
|
|
@@ -1409,7 +1409,7 @@ function runGit(root, args) {
|
|
|
1409
1409
|
if (!stdout) return "(no diff)";
|
|
1410
1410
|
return stdout;
|
|
1411
1411
|
}
|
|
1412
|
-
default: throw new Error(
|
|
1412
|
+
default: throw new Error(`Unknown action: ${args.action}`);
|
|
1413
1413
|
}
|
|
1414
1414
|
}
|
|
1415
1415
|
//#endregion
|
|
@@ -1428,33 +1428,33 @@ function renderText$8(value) {
|
|
|
1428
1428
|
}
|
|
1429
1429
|
const gitTool = defineTool({
|
|
1430
1430
|
name: "cc_git",
|
|
1431
|
-
description: "CCC
|
|
1431
|
+
description: "CCC git operations (cc-git semantics, aligned with osp). status/commit/push/log/pull/diff; non-fast-forward push/pull outputs [REJECTED] + suggested actions (never auto-force). merge/rebase/conflict resolution go through bash.",
|
|
1432
1432
|
parameters: {
|
|
1433
1433
|
action: {
|
|
1434
1434
|
type: "string",
|
|
1435
1435
|
enum: [...GIT_ACTIONS],
|
|
1436
1436
|
required: true,
|
|
1437
|
-
description: "
|
|
1437
|
+
description: "Subcommand: status/commit/push/log/pull/diff"
|
|
1438
1438
|
},
|
|
1439
1439
|
message: {
|
|
1440
1440
|
type: "string",
|
|
1441
|
-
description: "
|
|
1441
|
+
description: "Commit message (required for commit)"
|
|
1442
1442
|
},
|
|
1443
1443
|
count: {
|
|
1444
1444
|
type: "integer",
|
|
1445
|
-
description: "
|
|
1445
|
+
description: "Log count (default 10, max 100; runtime clamp 1-100)"
|
|
1446
1446
|
},
|
|
1447
1447
|
staged: {
|
|
1448
1448
|
type: "boolean",
|
|
1449
|
-
description: "diff:
|
|
1449
|
+
description: "diff: show staged changes (--cached)"
|
|
1450
1450
|
},
|
|
1451
1451
|
ref: {
|
|
1452
1452
|
type: "string",
|
|
1453
|
-
description: "diff:
|
|
1453
|
+
description: "diff: compare ref (e.g. HEAD~1 / main / origin/main)"
|
|
1454
1454
|
},
|
|
1455
1455
|
path: {
|
|
1456
1456
|
type: "string",
|
|
1457
|
-
description: "diff:
|
|
1457
|
+
description: "diff: restrict to path (e.g. src/, package.json)"
|
|
1458
1458
|
}
|
|
1459
1459
|
},
|
|
1460
1460
|
output: {
|
|
@@ -1491,10 +1491,10 @@ function readCccName(root) {
|
|
|
1491
1491
|
*/
|
|
1492
1492
|
function assertPathInsideRoot(root, value, flagName) {
|
|
1493
1493
|
const abs = resolve(root, value);
|
|
1494
|
-
if (classifyPath(abs, root) === "outside") throw new Error(`Path escape blocked: --${flagName}=${value}
|
|
1494
|
+
if (classifyPath(abs, root) === "outside") throw new Error(`Path escape blocked: --${flagName}=${value} escapes the CCC root`);
|
|
1495
1495
|
if (existsSync(abs)) try {
|
|
1496
1496
|
const real = realpathSync(abs);
|
|
1497
|
-
if (classifyPath(real, root) === "outside") throw new Error(`Path escape blocked: --${flagName}=${value}
|
|
1497
|
+
if (classifyPath(real, root) === "outside") throw new Error(`Path escape blocked: --${flagName}=${value} resolves via symlink outside the root (${real})`);
|
|
1498
1498
|
} catch (e) {
|
|
1499
1499
|
if (e instanceof Error && e.message.includes("symlink")) throw e;
|
|
1500
1500
|
}
|
|
@@ -1523,58 +1523,58 @@ const MSM_ACTIONS = [
|
|
|
1523
1523
|
"guide",
|
|
1524
1524
|
"ccc-config"
|
|
1525
1525
|
];
|
|
1526
|
-
const MSM_GUIDE = `MSM
|
|
1526
|
+
const MSM_GUIDE = `MSM Development Manual (Mech & Semi-Mech framework)
|
|
1527
1527
|
|
|
1528
|
-
##
|
|
1529
|
-
MSM =
|
|
1530
|
-
MSM
|
|
1528
|
+
## What it is
|
|
1529
|
+
MSM = the executable-unit layer. Mech is pure TS with zero LLM reasoning; Semi-Mech is a TS framework + LLM decision points.
|
|
1530
|
+
MSM is ACC's deterministic executable-unit layer — all shell/exec operations go through MSM and cannot be bypassed.
|
|
1531
1531
|
|
|
1532
|
-
##
|
|
1533
|
-
1.
|
|
1534
|
-
2. acc_msm register <name> --skill <s> --path
|
|
1535
|
-
3.
|
|
1536
|
-
4.
|
|
1532
|
+
## Registering a new MSM (acc_msm register)
|
|
1533
|
+
1. Write the script under <skill>/scripts/ (runnable via tsx; must include a main() CLI guard with an import.meta.url check)
|
|
1534
|
+
2. acc_msm register <name> --skill <s> --path <script path relative to root> --category <mech|semi-mech> --description <desc>
|
|
1535
|
+
3. Auto-writes mech-registry.json (preserving the original format) + git commit (commits only the registry file)
|
|
1536
|
+
4. Validation: path must be inside root, script must exist, name must be globally unique
|
|
1537
1537
|
|
|
1538
|
-
## flag schema
|
|
1539
|
-
flags
|
|
1540
|
-
[{"name":"output","type":"string","description":"
|
|
1541
|
-
{"name":"target","type":"path","description":"
|
|
1542
|
-
{"name":"force","type":"boolean","description":"
|
|
1543
|
-
- {name, type}
|
|
1544
|
-
- {flag, description}
|
|
1545
|
-
-
|
|
1538
|
+
## flag schema (v1)
|
|
1539
|
+
flags is a new-style object array for parameter validation and path-escape guarding:
|
|
1540
|
+
[{"name":"output","type":"string","description":"output path"},
|
|
1541
|
+
{"name":"target","type":"path","description":"operation target (type:path enables in-root validation + symlink defense)"},
|
|
1542
|
+
{"name":"force","type":"boolean","description":"force mode","default":false}]
|
|
1543
|
+
- {name, type} format — new style, type:"path" enables the path-escape guard
|
|
1544
|
+
- {flag, description} format — old style, CLI flag description string
|
|
1545
|
+
- On registration, flags are passed via acc_msm register --flags '<json>' (the tool currently parses the name style)
|
|
1546
1546
|
|
|
1547
|
-
##
|
|
1548
|
-
-
|
|
1549
|
-
-
|
|
1550
|
-
- main() CLI
|
|
1547
|
+
## Script conventions
|
|
1548
|
+
- Top-of-file documentation: purpose / usage / exit codes
|
|
1549
|
+
- Exit codes: 0 success / 1 user / 2 system / 3 operator (per ACC protocol classification)
|
|
1550
|
+
- main() CLI guard (DC-M2): the script must start with
|
|
1551
1551
|
if (import.meta.url === \`file://\${process.argv[1]}\`) { main() }
|
|
1552
|
-
|
|
1553
|
-
-
|
|
1554
|
-
-
|
|
1552
|
+
or an equivalent isMain / require.main === check — so vitest imports never trigger top-level code
|
|
1553
|
+
- A paired .test.ts or .spec.ts (DC-M1, vitest)
|
|
1554
|
+
- Business subprocess environment: SERENITY_ROOT / SERENITY_CCC / SERENITY_VERSION injected
|
|
1555
1555
|
|
|
1556
|
-
##
|
|
1557
|
-
-
|
|
1558
|
-
readline / prompt / process.stdin
|
|
1559
|
-
-
|
|
1560
|
-
|
|
1561
|
-
- agent
|
|
1562
|
-
-
|
|
1563
|
-
1.
|
|
1564
|
-
exit
|
|
1565
|
-
2. agent
|
|
1566
|
-
-
|
|
1567
|
-
-
|
|
1568
|
-
|
|
1556
|
+
## Interaction & confirmation conventions (no blocking confirmation)
|
|
1557
|
+
- MSMs run in **user-less subprocesses** (spawn/execFile, 600s timeout) — **never** use
|
|
1558
|
+
readline / prompt / process.stdin etc. to block waiting for user input (it would hang until killed by the timeout)
|
|
1559
|
+
- When a second confirmation is needed: **directly return the confirmation request** (list the operation to perform and its impact + how to retry with the confirmation flag),
|
|
1560
|
+
returning a non-zero exit code (or an explicit hint)
|
|
1561
|
+
- After the agent confirms, **re-invoke the MSM with the confirmation flag** (e.g. --confirm / --yes / --force) to retry
|
|
1562
|
+
- Standard mode (two-phase):
|
|
1563
|
+
1. First call: destructive/confirmation-requiring operation detected and no confirmation flag present → output the confirmation request (list operation/impact/rollback),
|
|
1564
|
+
exit non-zero (e.g. 1 user), **perform no changes**
|
|
1565
|
+
2. After the agent evaluates, re-invoke: with the confirmation flag → execute and output the result
|
|
1566
|
+
- Applies to: delete/overwrite/push/batch operations and other irreversible or wide-impact operations
|
|
1567
|
+
- Anti-example (forbidden): readline waiting for user input, process.stdin.on('data') blocking waits
|
|
1568
|
+
(MSMs have no stdin interaction channel — they hang until the 600s timeout)
|
|
1569
1569
|
|
|
1570
|
-
##
|
|
1571
|
-
DC-M1
|
|
1572
|
-
DC-M3
|
|
1570
|
+
## Quality checks (acc_msm check, DC-M1~M4)
|
|
1571
|
+
DC-M1 has .test.ts/.spec.ts; DC-M2 has a main() guard (function main( / isMain / require.main === / import.meta.url);
|
|
1572
|
+
DC-M3 bidirectional: scripts unregistered + registry references missing scripts; DC-M4 path-type flags marked type:"path"
|
|
1573
1573
|
|
|
1574
|
-
##
|
|
1575
|
-
acc_msm exec <name> --list —
|
|
1576
|
-
acc_msm exec <name> --schema <n> —
|
|
1577
|
-
acc_msm exec <name> --format=json — JSON
|
|
1574
|
+
## Self-description (protocol flags, first argument only)
|
|
1575
|
+
acc_msm exec <name> --list — list all MSMs
|
|
1576
|
+
acc_msm exec <name> --schema <n> — view a specific MSM's parameter schema
|
|
1577
|
+
acc_msm exec <name> --format=json — JSON output mode (remaining args passed through losslessly)
|
|
1578
1578
|
`;
|
|
1579
1579
|
/** CCC 配置参考(对齐 osp ccc-config action) */
|
|
1580
1580
|
const CCC_CONFIG_REFERENCE = `═══ CCC Configuration Reference ═══
|
|
@@ -1720,8 +1720,8 @@ function runMsm(root, args) {
|
|
|
1720
1720
|
case "register": {
|
|
1721
1721
|
const name = args.name ?? "";
|
|
1722
1722
|
const { skill, path, category, description } = args;
|
|
1723
|
-
if (!name) throw new Error("register
|
|
1724
|
-
if (!path || !category || !description) throw new Error("register
|
|
1723
|
+
if (!name) throw new Error("register requires name");
|
|
1724
|
+
if (!path || !category || !description) throw new Error("register requires path/category/description");
|
|
1725
1725
|
const scriptAbs = resolve(root, path);
|
|
1726
1726
|
if (classifyPath(scriptAbs, root) === "outside") throw new Error(`MSM register: path "${path}" escapes CCC root "${root}"`);
|
|
1727
1727
|
if (!existsSync(scriptAbs)) throw new Error(`MSM script not found: "${path}"`);
|
|
@@ -1735,7 +1735,7 @@ function runMsm(root, args) {
|
|
|
1735
1735
|
if (!Array.isArray(parsed)) throw new Error("flags must be a JSON array");
|
|
1736
1736
|
flags = parsed;
|
|
1737
1737
|
} catch (e) {
|
|
1738
|
-
throw new Error(`register flags
|
|
1738
|
+
throw new Error(`register flags parse failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1739
1739
|
}
|
|
1740
1740
|
entries.push({
|
|
1741
1741
|
name,
|
|
@@ -1848,7 +1848,7 @@ function runMsm(root, args) {
|
|
|
1848
1848
|
issues
|
|
1849
1849
|
};
|
|
1850
1850
|
}
|
|
1851
|
-
default: throw new Error(
|
|
1851
|
+
default: throw new Error(`Unknown action: ${args.action}`);
|
|
1852
1852
|
}
|
|
1853
1853
|
}
|
|
1854
1854
|
function prepareExec(root, args) {
|
|
@@ -2013,46 +2013,46 @@ function renderText$7(value) {
|
|
|
2013
2013
|
}
|
|
2014
2014
|
const msmTool = defineTool({
|
|
2015
2015
|
name: "acc_msm",
|
|
2016
|
-
description: "MSM
|
|
2016
|
+
description: "MSM (Mech & Semi-Mech) framework: list lists registered MSMs (header+flags display); exec executes (600s timeout, path-escape + symlink blocking, injects SERENITY_ROOT/CCC/VERSION env, appends --help TIP on failure; first arg --list/--schema/--format=json is protocol); register/deregister manage the registry (path inside root + script exists + globally unique validation, auto git precision commit); check quality checks DC-M1~M4; guide development manual; ccc-config CCC config reference (loop.defaultModel/sessionKeeper.threshold/localstore.gitTrack/hooks.autoRestoreSession). Reuses the CCC mech-registry.json.",
|
|
2017
2017
|
parameters: {
|
|
2018
2018
|
action: {
|
|
2019
2019
|
type: "string",
|
|
2020
2020
|
enum: [...MSM_ACTIONS],
|
|
2021
2021
|
required: true,
|
|
2022
|
-
description: "
|
|
2022
|
+
description: "Subcommand: list/exec/register/deregister/check/guide/ccc-config"
|
|
2023
2023
|
},
|
|
2024
2024
|
name: {
|
|
2025
2025
|
type: "string",
|
|
2026
|
-
description: "MSM
|
|
2026
|
+
description: "MSM name (exec/register/deregister)"
|
|
2027
2027
|
},
|
|
2028
2028
|
args: {
|
|
2029
2029
|
type: "array",
|
|
2030
2030
|
items: { type: "string" },
|
|
2031
|
-
description: "exec
|
|
2031
|
+
description: "exec business args (first arg --list/--schema <n>/--format=json is protocol flag, rest passed through losslessly)"
|
|
2032
2032
|
},
|
|
2033
2033
|
skill: {
|
|
2034
2034
|
type: "string",
|
|
2035
|
-
description: "register
|
|
2035
|
+
description: "register owning skill"
|
|
2036
2036
|
},
|
|
2037
2037
|
path: {
|
|
2038
2038
|
type: "string",
|
|
2039
|
-
description: "register
|
|
2039
|
+
description: "register script relative path (must be inside root and exist)"
|
|
2040
2040
|
},
|
|
2041
2041
|
category: {
|
|
2042
2042
|
type: "string",
|
|
2043
|
-
description: "register
|
|
2043
|
+
description: "register category (mech/semi-mech)"
|
|
2044
2044
|
},
|
|
2045
2045
|
description: {
|
|
2046
2046
|
type: "string",
|
|
2047
|
-
description: "register
|
|
2047
|
+
description: "register description"
|
|
2048
2048
|
},
|
|
2049
2049
|
flags: {
|
|
2050
2050
|
type: "string",
|
|
2051
|
-
description: "register
|
|
2051
|
+
description: "register flags JSON string (new-style object array; type:\"path\" enables escape validation)"
|
|
2052
2052
|
},
|
|
2053
2053
|
usage: {
|
|
2054
2054
|
type: "string",
|
|
2055
|
-
description: "register
|
|
2055
|
+
description: "register custom usage (auto-generated by default)"
|
|
2056
2056
|
}
|
|
2057
2057
|
},
|
|
2058
2058
|
output: {
|
|
@@ -2072,31 +2072,31 @@ const msmTool = defineTool({
|
|
|
2072
2072
|
*
|
|
2073
2073
|
* 内嵌框架内容(自包含,不依赖已安装技能);可选 section 参数聚焦某原则。
|
|
2074
2074
|
*/
|
|
2075
|
-
const EAP_CONTENT = `# EAP
|
|
2075
|
+
const EAP_CONTENT = `# EAP Cognitive Quality Framework (Explicit Abstraction Principle)
|
|
2076
2076
|
|
|
2077
|
-
> "
|
|
2077
|
+
> "The functional value of a thought is proportional to its external reconstructability."
|
|
2078
2078
|
|
|
2079
|
-
##
|
|
2080
|
-
|
|
|
2081
|
-
|
|
2082
|
-
| E↑
|
|
2083
|
-
| R↓
|
|
2084
|
-
| S↑
|
|
2079
|
+
## Three Variables
|
|
2080
|
+
| Variable | Meaning | How to improve |
|
|
2081
|
+
|----------|---------|----------------|
|
|
2082
|
+
| E↑ Explicitness | Degree to which variables/entities/relations are clearly defined | Define variables, state relationship direction & cardinality, draw boundaries |
|
|
2083
|
+
| R↓ Reconstructability | Cost of rebuilding the original reasoning later | Record decision rationale, context, constraints, alternatives |
|
|
2084
|
+
| S↑ Stability | Degree to which the same input repeatedly produces consistent output | Fix structures, protocolize, avoid relying on implicit context |
|
|
2085
2085
|
|
|
2086
|
-
##
|
|
2087
|
-
- [ ]
|
|
2088
|
-
- [ ]
|
|
2089
|
-
- [ ]
|
|
2090
|
-
- [ ]
|
|
2091
|
-
- [ ]
|
|
2092
|
-
- [ ]
|
|
2093
|
-
- [ ]
|
|
2086
|
+
## Pre-Output Self-Check Checklist
|
|
2087
|
+
- [ ] Variables/entities clearly defined (E↑)
|
|
2088
|
+
- [ ] Relationships state direction/cardinality (E↑)
|
|
2089
|
+
- [ ] Boundaries drawn — what is in scope / what is not (E↑)
|
|
2090
|
+
- [ ] No ambiguous words: "handle" "optimize" "problem" → be specific (E↑)
|
|
2091
|
+
- [ ] Key decisions record rationale and alternatives (R↓)
|
|
2092
|
+
- [ ] No level-skipping — align the upper layer before descending (R↓)
|
|
2093
|
+
- [ ] Structures can be regenerated repeatably (S↑)
|
|
2094
2094
|
|
|
2095
|
-
##
|
|
2096
|
-
ACC
|
|
2097
|
-
CCC
|
|
2095
|
+
## Relationship with ACC
|
|
2096
|
+
ACC (plugin/template) encodes structure as code (E↑); generating a CCC from ACC is deterministic (R↓); consistent across multiple CCCs (S↑).
|
|
2097
|
+
CCC (home-serenity etc.) encodes cognitive content as skills/SESSIONs/design docs. Use this checklist to self-check outputs.
|
|
2098
2098
|
|
|
2099
|
-
##
|
|
2099
|
+
## Reference
|
|
2100
2100
|
https://github.com/tellmewhattodo/theory-eap`;
|
|
2101
2101
|
function renderText$6(value) {
|
|
2102
2102
|
return [{
|
|
@@ -2106,7 +2106,7 @@ function renderText$6(value) {
|
|
|
2106
2106
|
}
|
|
2107
2107
|
const eapTool = defineTool({
|
|
2108
2108
|
name: "eap",
|
|
2109
|
-
description: "EAP
|
|
2109
|
+
description: "EAP cognitive quality framework (progressive disclosure): defines E↑ explicitness / R↓ reconstructability / S↑ stability + pre-output self-check checklist. No section returns the full framework; specify a section to focus.",
|
|
2110
2110
|
parameters: { section: {
|
|
2111
2111
|
type: "string",
|
|
2112
2112
|
enum: [
|
|
@@ -2114,16 +2114,16 @@ const eapTool = defineTool({
|
|
|
2114
2114
|
"checklist",
|
|
2115
2115
|
"acc"
|
|
2116
2116
|
],
|
|
2117
|
-
description: "
|
|
2117
|
+
description: "Focus section: variables (three variables) / checklist (self-check list) / acc (relationship with ACC)"
|
|
2118
2118
|
} },
|
|
2119
2119
|
output: {
|
|
2120
2120
|
schema: { type: "string" },
|
|
2121
2121
|
render: (_args, value) => renderText$6(value)
|
|
2122
2122
|
},
|
|
2123
2123
|
async execute(args) {
|
|
2124
|
-
if (args.section === "variables") return EAP_CONTENT.split("##
|
|
2125
|
-
if (args.section === "checklist") return EAP_CONTENT.match(/##
|
|
2126
|
-
if (args.section === "acc") return EAP_CONTENT.match(/##
|
|
2124
|
+
if (args.section === "variables") return EAP_CONTENT.split("## Pre-Output Self-Check Checklist")[0];
|
|
2125
|
+
if (args.section === "checklist") return EAP_CONTENT.match(/## Pre-Output Self-Check Checklist[\s\S]*?(?=## )/)?.[0] ?? EAP_CONTENT;
|
|
2126
|
+
if (args.section === "acc") return EAP_CONTENT.match(/## Relationship with ACC[\s\S]*/)?.[0] ?? EAP_CONTENT;
|
|
2127
2127
|
return EAP_CONTENT;
|
|
2128
2128
|
}
|
|
2129
2129
|
});
|
|
@@ -2132,33 +2132,33 @@ const eapTool = defineTool({
|
|
|
2132
2132
|
/**
|
|
2133
2133
|
* neat.ts — Neat 设计协作协议工具(渐进式披露,ACC 标准工具化)
|
|
2134
2134
|
*/
|
|
2135
|
-
const NEAT_CONTENT = `# Neat
|
|
2135
|
+
const NEAT_CONTENT = `# Neat Design Collaboration Protocol
|
|
2136
2136
|
|
|
2137
|
-
>
|
|
2137
|
+
> Complex design is not thought up at once — it is walked out in small aligned steps.
|
|
2138
2138
|
|
|
2139
|
-
##
|
|
2140
|
-
|
|
|
2141
|
-
|
|
2142
|
-
|
|
|
2143
|
-
|
|
|
2144
|
-
|
|
|
2145
|
-
|
|
|
2139
|
+
## Four Iron Rules
|
|
2140
|
+
| Rule | Meaning | Anti-example |
|
|
2141
|
+
|------|---------|--------------|
|
|
2142
|
+
| Small-step alignment | Advance one decision at a time; confirm before the next step | Presenting 10 options at once |
|
|
2143
|
+
| Explicit decisions | Every choice records rationale and alternatives | "I feel this is good" (no rationale) |
|
|
2144
|
+
| Document-driven | Conclusions land in named files (<subject>-<scope>-<type>.md) | Conclusions exist only in conversation |
|
|
2145
|
+
| No level-skipping | Advance strictly through the layers | Writing implementation before requirements are aligned |
|
|
2146
2146
|
|
|
2147
|
-
##
|
|
2148
|
-
|
|
2149
|
-
|
|
|
2150
|
-
|
|
2151
|
-
|
|
|
2152
|
-
|
|
|
2153
|
-
|
|
|
2154
|
-
|
|
|
2155
|
-
|
|
|
2147
|
+
## Five-Layer Progression (no skipping)
|
|
2148
|
+
Requirements → Scope → Solution → Interface → Implementation
|
|
2149
|
+
| Layer | Artifact | Question |
|
|
2150
|
+
|-------|----------|----------|
|
|
2151
|
+
| Requirements | Requirements description | "What problem are we solving?" |
|
|
2152
|
+
| Scope | Scope list (in/out) | "What is in, explicitly what is out?" |
|
|
2153
|
+
| Solution | Solution comparison + selection | "How? Which one? Why?" |
|
|
2154
|
+
| Interface | Interface/protocol definition | "How do the boundaries interact?" |
|
|
2155
|
+
| Implementation | Code/docs | "Land it layer by layer" |
|
|
2156
2156
|
|
|
2157
|
-
##
|
|
2158
|
-
1.
|
|
2159
|
-
2.
|
|
2160
|
-
3.
|
|
2161
|
-
4.
|
|
2157
|
+
## Collaboration Rhythm
|
|
2158
|
+
1. Raise one decision point of the current layer (with context + suggestion + rationale)
|
|
2159
|
+
2. Wait for confirmation or correction (small step)
|
|
2160
|
+
3. After confirmation, record the decision (into session SESSION.md or a design doc)
|
|
2161
|
+
4. Move to the next decision point`;
|
|
2162
2162
|
function renderText$5(value) {
|
|
2163
2163
|
return [{
|
|
2164
2164
|
type: "text",
|
|
@@ -2167,19 +2167,19 @@ function renderText$5(value) {
|
|
|
2167
2167
|
}
|
|
2168
2168
|
const neatTool = defineTool({
|
|
2169
2169
|
name: "neat",
|
|
2170
|
-
description: "Neat
|
|
2170
|
+
description: "Neat design collaboration protocol (progressive disclosure): small-step alignment / explicit decisions / document-driven / no level-skipping (requirements→scope→solution→interface→implementation). No section returns the full protocol.",
|
|
2171
2171
|
parameters: { section: {
|
|
2172
2172
|
type: "string",
|
|
2173
2173
|
enum: ["rules", "layers"],
|
|
2174
|
-
description: "
|
|
2174
|
+
description: "Focus section: rules (four iron rules) / layers (five-layer progression)"
|
|
2175
2175
|
} },
|
|
2176
2176
|
output: {
|
|
2177
2177
|
schema: { type: "string" },
|
|
2178
2178
|
render: (_args, value) => renderText$5(value)
|
|
2179
2179
|
},
|
|
2180
2180
|
async execute(args) {
|
|
2181
|
-
if (args.section === "rules") return NEAT_CONTENT.match(/##
|
|
2182
|
-
if (args.section === "layers") return NEAT_CONTENT.match(/##
|
|
2181
|
+
if (args.section === "rules") return NEAT_CONTENT.match(/## Four Iron Rules[\s\S]*?(?=## )/)?.[0] ?? NEAT_CONTENT;
|
|
2182
|
+
if (args.section === "layers") return NEAT_CONTENT.match(/## Five-Layer Progression[\s\S]*?(?=## )/)?.[0] ?? NEAT_CONTENT;
|
|
2183
2183
|
return NEAT_CONTENT;
|
|
2184
2184
|
}
|
|
2185
2185
|
});
|
|
@@ -2192,52 +2192,52 @@ const neatTool = defineTool({
|
|
|
2192
2192
|
* 不可逆不确定性约束下,维持认知实体身份、可达性与演化能力的工程学科。
|
|
2193
2193
|
* 来源:home-serenity `.opencode/skills/cce/SKILL.md` + CCE 理论。
|
|
2194
2194
|
*/
|
|
2195
|
-
const CCE_CONTENT = `# CCE —
|
|
2195
|
+
const CCE_CONTENT = `# CCE — Cognitive Continuity Engineering
|
|
2196
2196
|
|
|
2197
|
-
>
|
|
2198
|
-
> CCE
|
|
2197
|
+
> **Cognitive Continuity Engineering is the engineering discipline of maintaining a cognitive entity's identity, accessibility, and capacity to evolve under the constraints of bounded resources and irreversible uncertainty.**
|
|
2198
|
+
> CCE does not optimize cognition. It preserves the conditions under which cognition can continue. — Persistence Engineering, not performance engineering
|
|
2199
2199
|
|
|
2200
|
-
##
|
|
2201
|
-
-
|
|
2202
|
-
-
|
|
2203
|
-
-
|
|
2200
|
+
## Core Propositions
|
|
2201
|
+
- **Continuity belongs to the container, not to any individual participant** — agents come and go, but the CCC's cognitive trajectory persists
|
|
2202
|
+
- **Organization must at minimum keep pace with accumulation** — otherwise operational cognitive entropy (H_op) grows unboundedly and accessibility is lost
|
|
2203
|
+
- **Reconstruction over preservation** — an artifact's value is determined by its ability to let future agents rebuild the original reasoning
|
|
2204
2204
|
|
|
2205
|
-
##
|
|
2206
|
-
|
|
2207
|
-
|
|
|
2208
|
-
|
|
2209
|
-
|
|
|
2210
|
-
|
|
|
2211
|
-
|
|
|
2212
|
-
|
|
|
2213
|
-
|
|
|
2205
|
+
## Cognitive Container
|
|
2206
|
+
A bounded cognitive space in which cognition can accumulate, reorganize, and evolve. 5 defining properties:
|
|
2207
|
+
| Property | Function |
|
|
2208
|
+
|----------|----------|
|
|
2209
|
+
| Identity | Distinguishes this cognitive system from others |
|
|
2210
|
+
| Boundaries | Define what is inside/outside the cognitive space |
|
|
2211
|
+
| Persistent Memory | Retains accumulated cognition across time |
|
|
2212
|
+
| Operational Constraints | Define which operations are allowed inside the container |
|
|
2213
|
+
| Evolutionary History | Records the trajectory of cognitive change, making reconstruction possible |
|
|
2214
2214
|
|
|
2215
|
-
##
|
|
2216
|
-
|
|
2215
|
+
## Operational Cognitive Entropy (H_op)
|
|
2216
|
+
Does not measure total entropy (not operational); it measures only the **excess cognitive cost** for agents to complete tasks inside the container:
|
|
2217
2217
|
> H_op(C, t) = cost(task | C, t) − cost(task | ideal)
|
|
2218
|
-
|
|
2218
|
+
Continuity condition: **H_op(C, t) ≤ H_critical** — agents can still complete tasks at reasonable cost
|
|
2219
2219
|
|
|
2220
|
-
##
|
|
2221
|
-
> **ΔH_org ≥ ΔH_in**
|
|
2220
|
+
## Continuity Maintenance Condition
|
|
2221
|
+
> **ΔH_org ≥ ΔH_in** — organization must at minimum keep pace with accumulation
|
|
2222
2222
|
|
|
2223
|
-
##
|
|
2224
|
-
Experience → Accumulation → Organization → Abstraction → Reconstruction → Evolution
|
|
2225
|
-
|
|
|
2226
|
-
|
|
2227
|
-
| Experience |
|
|
2228
|
-
| Accumulation |
|
|
2229
|
-
| Organization |
|
|
2230
|
-
| Abstraction |
|
|
2231
|
-
| Reconstruction |
|
|
2232
|
-
| Evolution |
|
|
2223
|
+
## Six-Phase Lifecycle
|
|
2224
|
+
Experience → Accumulation → Organization → Abstraction → Reconstruction → Evolution →(loop)
|
|
2225
|
+
| Phase | Engineering Concern |
|
|
2226
|
+
|-------|---------------------|
|
|
2227
|
+
| Experience | Does input carry enough structure |
|
|
2228
|
+
| Accumulation | Is information stored losslessly |
|
|
2229
|
+
| Organization | Entropy management — ΔH_org offsets ΔH_in |
|
|
2230
|
+
| Abstraction | Is abstraction explicitly encoded |
|
|
2231
|
+
| Reconstruction | Can reasoning structure be recovered from artifacts |
|
|
2232
|
+
| Evolution | Does evolution stay coherent or introduce drift |
|
|
2233
2233
|
|
|
2234
|
-
##
|
|
2235
|
-
EAP
|
|
2236
|
-
CCE
|
|
2234
|
+
## Relationship with EAP
|
|
2235
|
+
EAP answers "how a piece of knowledge should be structured" (explicitness E↑ / reconstructability R↓ / stability S↑);
|
|
2236
|
+
CCE answers "how structured knowledge should keep evolving across time without losing coherence". They complement each other: EAP is static quality, CCE is dynamic persistence.
|
|
2237
2237
|
|
|
2238
|
-
##
|
|
2239
|
-
Serenity
|
|
2240
|
-
CCC
|
|
2238
|
+
## Relationship with Serenity
|
|
2239
|
+
Serenity's session system, session tracking, and entropy management mechanisms (SQC quality loop) are all engineering implementations of CCE;
|
|
2240
|
+
the behavioral constraints embedded in CCC system prompts come from CCE.`;
|
|
2241
2241
|
function renderText$4(value) {
|
|
2242
2242
|
return [{
|
|
2243
2243
|
type: "text",
|
|
@@ -2246,7 +2246,7 @@ function renderText$4(value) {
|
|
|
2246
2246
|
}
|
|
2247
2247
|
const cceTool = defineTool({
|
|
2248
2248
|
name: "cce",
|
|
2249
|
-
description: "CCE
|
|
2249
|
+
description: "CCE cognitive continuity engineering (progressive disclosure): the engineering discipline of maintaining a cognitive entity's identity/accessibility/evolution under bounded resources and irreversible uncertainty. No section returns the full framework; specify a section to focus.",
|
|
2250
2250
|
parameters: { section: {
|
|
2251
2251
|
type: "string",
|
|
2252
2252
|
enum: [
|
|
@@ -2255,7 +2255,7 @@ const cceTool = defineTool({
|
|
|
2255
2255
|
"lifecycle",
|
|
2256
2256
|
"eap"
|
|
2257
2257
|
],
|
|
2258
|
-
description: "
|
|
2258
|
+
description: "Focus section: container (5 cognitive-container properties) / entropy (operational entropy H_op) / lifecycle (six phases) / eap (relationship with EAP)"
|
|
2259
2259
|
} },
|
|
2260
2260
|
output: {
|
|
2261
2261
|
schema: { type: "string" },
|
|
@@ -2265,15 +2265,15 @@ const cceTool = defineTool({
|
|
|
2265
2265
|
const section = args.section;
|
|
2266
2266
|
const blocks = {
|
|
2267
2267
|
container: {
|
|
2268
|
-
start: "##
|
|
2269
|
-
end: "##
|
|
2268
|
+
start: "## Cognitive Container",
|
|
2269
|
+
end: "## Operational Cognitive Entropy"
|
|
2270
2270
|
},
|
|
2271
2271
|
entropy: {
|
|
2272
|
-
start: "##
|
|
2273
|
-
end: "##
|
|
2272
|
+
start: "## Operational Cognitive Entropy (H_op)",
|
|
2273
|
+
end: "## Continuity Maintenance Condition"
|
|
2274
2274
|
},
|
|
2275
|
-
lifecycle: { start: "##
|
|
2276
|
-
eap: { start: "##
|
|
2275
|
+
lifecycle: { start: "## Six-Phase Lifecycle" },
|
|
2276
|
+
eap: { start: "## Relationship with EAP" }
|
|
2277
2277
|
};
|
|
2278
2278
|
if (section) {
|
|
2279
2279
|
const b = blocks[section];
|
|
@@ -2343,11 +2343,11 @@ function writeProgress(root, label, p) {
|
|
|
2343
2343
|
}, null, 2) + "\n", "utf-8");
|
|
2344
2344
|
const lines = [
|
|
2345
2345
|
`# loop: ${label}`,
|
|
2346
|
-
`-
|
|
2347
|
-
`-
|
|
2348
|
-
`-
|
|
2346
|
+
`- Model: ${p.model}`,
|
|
2347
|
+
`- Round: ${p.round}`,
|
|
2348
|
+
`- Done: ${p.done}`,
|
|
2349
2349
|
"",
|
|
2350
|
-
`##
|
|
2350
|
+
`## Latest response`,
|
|
2351
2351
|
"",
|
|
2352
2352
|
p.lastResponse,
|
|
2353
2353
|
""
|
|
@@ -2389,66 +2389,66 @@ function splitModel(model) {
|
|
|
2389
2389
|
/** 轮次 prompt(对齐老 loop 结构:回顾进度 → 自由工作 → 汇报;S134 EAP 化:固定详尽) */
|
|
2390
2390
|
function buildRoundPrompt(opts) {
|
|
2391
2391
|
const { root, session, label, round, stopToken, progress, task } = opts;
|
|
2392
|
-
const resumeNote = progress && progress.round > 0 ?
|
|
2393
|
-
return `# ${label} —
|
|
2392
|
+
const resumeNote = progress && progress.round > 0 ? `Previous round (round ${progress.round}) completed: ${progress.lastResponse.slice(0, 300)}\nAlways continue from where you left off; never redo completed work.` : "This is the first round.";
|
|
2393
|
+
return `# ${label} — workhorse loop round ${round}
|
|
2394
2394
|
|
|
2395
|
-
CCC
|
|
2396
|
-
${session ?
|
|
2397
|
-
${task ?
|
|
2395
|
+
CCC root: ${root}
|
|
2396
|
+
${session ? `Work session: ${session} (progress recorded in AGENT_SESSIONS/${session}/SESSION.md)` : ""}
|
|
2397
|
+
${task ? `Task: ${task}` : `Task: follow the work corresponding to label "${label}" (if a work session exists, read SESSION.md first to clarify the goal)`}
|
|
2398
2398
|
${resumeNote}
|
|
2399
2399
|
|
|
2400
|
-
##
|
|
2401
|
-
1.
|
|
2402
|
-
2.
|
|
2403
|
-
|
|
2404
|
-
- E↑
|
|
2405
|
-
- R↓
|
|
2406
|
-
- S↑
|
|
2407
|
-
3.
|
|
2400
|
+
## Work rules (fixed every round, must follow)
|
|
2401
|
+
1. Work freely within this round: read files, modify code, execute commands — use every means to advance the task.
|
|
2402
|
+
2. If this task is **reading/curating or text-writing work** (extracting from files, summarizing, writing docs, generating text, etc.),
|
|
2403
|
+
first load eap (acc-eap skill) and organize output per the EAP standard:
|
|
2404
|
+
- E↑ Explicit: entities/variables clearly defined, relationships with direction and cardinality, boundaries drawn, no ambiguous words
|
|
2405
|
+
- R↓ Reconstructable: key conclusions record sources and reasoning, rebuildable by later agents
|
|
2406
|
+
- S↑ Stable: output structure regenerates repeatably, no reliance on implicit context
|
|
2407
|
+
3. Reports must be concrete and verifiable — no filler.
|
|
2408
2408
|
|
|
2409
|
-
##
|
|
2410
|
-
1.
|
|
2411
|
-
2.
|
|
2412
|
-
3.
|
|
2409
|
+
## Per-round report (fixed format, answer each item)
|
|
2410
|
+
1. What was done this round (concrete)
|
|
2411
|
+
2. Next-step plan
|
|
2412
|
+
3. Whether the task is complete (if complete, output only ${stopToken})
|
|
2413
2413
|
|
|
2414
|
-
|
|
2414
|
+
If the task is complete, output only ${stopToken}.`;
|
|
2415
2415
|
}
|
|
2416
2416
|
/**
|
|
2417
2417
|
* loop 规模化使用指引(guide 子命令输出;S134):
|
|
2418
2418
|
* 使用 loop 前必须先加载 eap 设计方案;并行策略;提示词规范(详尽固定 EAP);
|
|
2419
2419
|
* 阅读/文字编写类 loop 内部也加载 eap。
|
|
2420
2420
|
*/
|
|
2421
|
-
const LOOP_GUIDE = `# loop —
|
|
2421
|
+
const LOOP_GUIDE = `# loop — Scale-Up Usage Guide (guide)
|
|
2422
2422
|
|
|
2423
|
-
## ⚠️
|
|
2424
|
-
|
|
2423
|
+
## ⚠️ Before using: load eap and design the plan
|
|
2424
|
+
Before calling loop, load eap (acc-eap skill) and design the "scale-up loop plan" based on the EAP framework.
|
|
2425
2425
|
|
|
2426
|
-
### 1.
|
|
2427
|
-
-
|
|
2428
|
-
-
|
|
2426
|
+
### 1. Task decomposition (E↑ Explicit)
|
|
2427
|
+
- Split large tasks into explicit subtasks: each subtask defines goal / input / boundaries (what to do, what not to do) / acceptance criteria
|
|
2428
|
+
- Make dependencies explicit: dependent tasks run serially, independent ones can run in parallel
|
|
2429
2429
|
|
|
2430
|
-
### 2.
|
|
2431
|
-
- task
|
|
2432
|
-
-
|
|
2433
|
-
|
|
2434
|
-
-
|
|
2435
|
-
loop
|
|
2430
|
+
### 2. Prompt design (loop's task parameter)
|
|
2431
|
+
- task must be detailed, fixed, and EAP-compliant: clear goal, drawn boundaries, decidable acceptance criteria
|
|
2432
|
+
- Anti-example "handle this file" — ambiguous; good example "read <path>, extract all rows of the「关键决策」table,
|
|
2433
|
+
output a JSON array (fields id/conclusion/evidence), do not modify the original file"
|
|
2434
|
+
- Reading/curating or text-writing work (extracting from files, summarizing, writing docs, generating text, etc.):
|
|
2435
|
+
the loop-internal agent is also required to load eap and organize output per the EAP standard
|
|
2436
2436
|
|
|
2437
|
-
### 3.
|
|
2438
|
-
-
|
|
2439
|
-
-
|
|
2440
|
-
a.
|
|
2441
|
-
b. workflow
|
|
2442
|
-
-
|
|
2443
|
-
|
|
2444
|
-
-
|
|
2437
|
+
### 3. Parallel strategy (scale-up)
|
|
2438
|
+
- Independent subtasks can run in parallel: each subtask gets its own loop (own label + task)
|
|
2439
|
+
- Parallel execution:
|
|
2440
|
+
a. Background subagents: each subagent calls loop (own label), subagents run in parallel
|
|
2441
|
+
b. workflow: parallel phase drives multiple loop sub-agents
|
|
2442
|
+
- Concurrency safety guaranteed: unique sessionId (loop-<label>-<uuid>), progress files isolated per label
|
|
2443
|
+
(AGENT_SESSIONS/loop-<label>.json) — same label resumes, different labels never interfere
|
|
2444
|
+
- Aggregation: after each parallel loop produces progress, the main agent merges (or spawns one aggregation loop)
|
|
2445
2445
|
|
|
2446
|
-
##
|
|
2447
|
-
-
|
|
2448
|
-
- agent
|
|
2446
|
+
## Completion criteria
|
|
2447
|
+
- The only completion condition = the loop-internal agent echoes this round's random verification code (stop token); dialogue round cap 100 (osp fail-safe, forced stop beyond the cap, resumable)
|
|
2448
|
+
- Automatic restart on abnormal agent stop (≤100 restarts, anti-infinite-loop)
|
|
2449
2449
|
|
|
2450
|
-
##
|
|
2451
|
-
- WebUI
|
|
2450
|
+
## Waiting UI
|
|
2451
|
+
- The WebUI session-header Serenity detail card shows running loops' progress (label / round / last response), one line per parallel task, ~3s refresh
|
|
2452
2452
|
`;
|
|
2453
2453
|
/** 列出 AGENT_SESSIONS/loop-*.json 的全部进度(按 updated 倒序;坏文件跳过) */
|
|
2454
2454
|
function listActiveLoops(root) {
|
|
@@ -2537,29 +2537,29 @@ function lastAssistantText(agent) {
|
|
|
2537
2537
|
function createLoopTool(ctx) {
|
|
2538
2538
|
return defineTool({
|
|
2539
2539
|
name: "loop",
|
|
2540
|
-
description: "
|
|
2540
|
+
description: "The workhorse loop (equivalent to the legacy loop): create a dedicated agent with the specified model and run it repeatedly until the task is done.\nUsage: loop guide (print the scale-up usage guide — load eap to design a scale-up plan before using); loop accepts task (the goal to complete) or relies on session context; default model reads loop.defaultModel from .opencode/serenity.json (currently minimax-cn-coding-plan/MiniMax-M3, the cheap workhorse).\nBehavior: an internal hard while-loop drives the agent round by round, each round waits with no timeout (the agent works as long as it needs). The only completion condition = the agent echoes this round's random completion code (stop token), preventing low-intelligence models from finishing early. Rounds need no caller input — dialogue round cap 100 (osp fail-safe; forced stop beyond the cap, resumable), automatic restart on abnormal stop (≤100 restarts, anti-infinite-loop fail-safe).\nProgress: written to AGENT_SESSIONS/loop-<label>.md/.json; calling again with the same label resumes from the last round (no redo).\nConstraints: loop agents carry full Serenity constraints (ACC identity / entry-skill system prompt / guards / trajectory-steward).\nExample: loop 执行「扫描 SQC 并修复 DC 问题」 (run SQC scan and fix DC issues), label: sqc-scan; loop guide",
|
|
2541
2541
|
parameters: {
|
|
2542
2542
|
task: {
|
|
2543
2543
|
type: "string",
|
|
2544
2544
|
required: true,
|
|
2545
|
-
description: "
|
|
2545
|
+
description: "The task goal to complete (required: tell the loop agent what to do)"
|
|
2546
2546
|
},
|
|
2547
2547
|
label: {
|
|
2548
2548
|
type: "string",
|
|
2549
2549
|
required: true,
|
|
2550
|
-
description: "
|
|
2550
|
+
description: "Task label (1-50 chars; progress file named loop-<label>.md/.json)"
|
|
2551
2551
|
},
|
|
2552
2552
|
session: {
|
|
2553
2553
|
type: "string",
|
|
2554
|
-
description: "
|
|
2554
|
+
description: "Work session S### (context hint, progress reference)"
|
|
2555
2555
|
},
|
|
2556
2556
|
model: {
|
|
2557
2557
|
type: "string",
|
|
2558
|
-
description: "provider/model
|
|
2558
|
+
description: "provider/model (e.g. minimax-cn-coding-plan/MiniMax-M3); default reads loop.defaultModel"
|
|
2559
2559
|
},
|
|
2560
2560
|
guide: {
|
|
2561
2561
|
type: "boolean",
|
|
2562
|
-
description: "
|
|
2562
|
+
description: "Print the scale-up usage guide (creates no agent; read before using loop — includes eap scale-up design requirements / parallelism strategy / prompt conventions)"
|
|
2563
2563
|
}
|
|
2564
2564
|
},
|
|
2565
2565
|
output: {
|
|
@@ -2572,16 +2572,16 @@ function createLoopTool(ctx) {
|
|
|
2572
2572
|
if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
|
|
2573
2573
|
const cfg = loadSerenityConfig(root, DEFAULT_SERENITY_CONFIG_PATHS);
|
|
2574
2574
|
const model = args.model ?? cfg.loop?.defaultModel;
|
|
2575
|
-
if (!model) throw new Error("loop
|
|
2575
|
+
if (!model) throw new Error("loop requires a model: pass --model or configure .opencode/serenity.json loop.defaultModel");
|
|
2576
2576
|
const label = args.label;
|
|
2577
|
-
if (!ctx.agentLoop) throw new Error("loop: ctx.agentLoop
|
|
2577
|
+
if (!ctx.agentLoop) throw new Error("loop: ctx.agentLoop unavailable");
|
|
2578
2578
|
const { provider, model: modelName } = splitModel(model);
|
|
2579
2579
|
const stopToken = newStopToken();
|
|
2580
2580
|
let progress = readProgress(root, label);
|
|
2581
2581
|
const startRound = progress ? progress.round + 1 : 1;
|
|
2582
2582
|
const parentCtx = exec.agent?.ctx;
|
|
2583
2583
|
const inherited = loopPresetInheritance(parentCtx);
|
|
2584
|
-
if (!ctx.agents) throw new Error("loop: ctx.agents
|
|
2584
|
+
if (!ctx.agents) throw new Error("loop: ctx.agents unavailable");
|
|
2585
2585
|
const parentSession = (exec.agent?.session)?.id;
|
|
2586
2586
|
let handle;
|
|
2587
2587
|
let loopAgent;
|
|
@@ -2692,10 +2692,10 @@ function createLoopTool(ctx) {
|
|
|
2692
2692
|
progressFile: json,
|
|
2693
2693
|
lastResponse: lastResponse.slice(0, 2e3),
|
|
2694
2694
|
usage: {
|
|
2695
|
-
how: `loop
|
|
2696
|
-
progress:
|
|
2697
|
-
constraints: "loop
|
|
2698
|
-
next: done ? "
|
|
2695
|
+
how: `loop drives the agent round by round via an internal hard while-loop; the agent terminates by echoing the random completion code; dialogue round cap 100 (osp fail-safe), automatic restart on abnormal stop (≤100 times)`,
|
|
2696
|
+
progress: `Progress in AGENT_SESSIONS/loop-${label}.md and .json; calling loop again with the same label resumes from the next round (no redo)`,
|
|
2697
|
+
constraints: "loop agents carry full Serenity constraints (ACC identity / entry-skill system prompt / guards / trajectory-steward)",
|
|
2698
|
+
next: done ? "Task completed; check the progress file to wrap up" : `Task incomplete (${finishReason}, hit 100 rounds or 100 restart fail-safe); resume with the same label`
|
|
2699
2699
|
}
|
|
2700
2700
|
};
|
|
2701
2701
|
}
|
|
@@ -4792,7 +4792,11 @@ function registerSettingsSection(ctx, config) {
|
|
|
4792
4792
|
setSource: (get) => {
|
|
4793
4793
|
simpleSource = get;
|
|
4794
4794
|
},
|
|
4795
|
-
onChange: () => {
|
|
4795
|
+
onChange: () => {
|
|
4796
|
+
try {
|
|
4797
|
+
ctx.emit?.("serenity/settings-changed");
|
|
4798
|
+
} catch {}
|
|
4799
|
+
}
|
|
4796
4800
|
});
|
|
4797
4801
|
}
|
|
4798
4802
|
//#endregion
|
|
@@ -5392,20 +5396,54 @@ function agentCwd$3(exec) {
|
|
|
5392
5396
|
return exec.agent?.session?.header?.cwd ?? process.cwd();
|
|
5393
5397
|
}
|
|
5394
5398
|
/** 当前 dsh 会话 id(use/close 按会话隔离的 scope) */
|
|
5395
|
-
function agentScope$
|
|
5399
|
+
function agentScope$2(exec) {
|
|
5396
5400
|
return exec.agent?.session?.id ?? "default";
|
|
5397
5401
|
}
|
|
5398
5402
|
/**
|
|
5399
|
-
*
|
|
5400
|
-
*
|
|
5401
|
-
*
|
|
5403
|
+
* 从激活会话派生命名标题(v1.22.9 格式修正):
|
|
5404
|
+
* F3 原始需求是 **`S###-日期`**(如 `S143-2026-08-26`)——从 `sessionId` 派生,
|
|
5405
|
+
* 而非完整目录名(`2026-08-24--S142--...` 超长 + 中文,不符合用户拍板格式)。
|
|
5406
|
+
* 无 S### 编号(issue 会话等)→ 回退目录名。
|
|
5407
|
+
* @returns `S143-2026-08-26` 或原目录名
|
|
5402
5408
|
*/
|
|
5403
|
-
function
|
|
5404
|
-
|
|
5405
|
-
if (
|
|
5406
|
-
|
|
5409
|
+
function namingTitleFor(active) {
|
|
5410
|
+
const sid = active.sessionId;
|
|
5411
|
+
if (typeof sid === "string" && /^S\d+$/.test(sid)) {
|
|
5412
|
+
const date = active.dirName.match(/^(\d{4}-\d{2}-\d{2})--/)?.[1] ?? "";
|
|
5413
|
+
if (date) return `${sid}-${date}`;
|
|
5414
|
+
return sid;
|
|
5415
|
+
}
|
|
5407
5416
|
return active.dirName;
|
|
5408
5417
|
}
|
|
5418
|
+
/**
|
|
5419
|
+
* use 激活宁静号会话后,把当前 dsh 会话重命名为命名标题(`S###-日期`)。
|
|
5420
|
+
* 门控:naming.enabled + sessionTitle 服务存在;失败不静默——返回 null 且输出
|
|
5421
|
+
* 失败原因(调用方 console.warn),保证可观测性(v1.22.9)。
|
|
5422
|
+
* @returns { title, ok } 或 { ok:false, reason }(未执行/失败均返回对象,非 null 歧义)
|
|
5423
|
+
*/
|
|
5424
|
+
function renameDshSessionOnUse(deps, session, rename, active) {
|
|
5425
|
+
if (!deps.namingEnabled) return {
|
|
5426
|
+
ok: false,
|
|
5427
|
+
reason: "naming.enabled=false"
|
|
5428
|
+
};
|
|
5429
|
+
if (!deps.sessionTitleAvailable) return {
|
|
5430
|
+
ok: false,
|
|
5431
|
+
reason: "sessionTitle service unavailable"
|
|
5432
|
+
};
|
|
5433
|
+
const title = namingTitleFor(active);
|
|
5434
|
+
try {
|
|
5435
|
+
rename(session, title);
|
|
5436
|
+
return {
|
|
5437
|
+
ok: true,
|
|
5438
|
+
title
|
|
5439
|
+
};
|
|
5440
|
+
} catch (error) {
|
|
5441
|
+
return {
|
|
5442
|
+
ok: false,
|
|
5443
|
+
reason: `rename threw: ${String(error?.message ?? error)}`
|
|
5444
|
+
};
|
|
5445
|
+
}
|
|
5446
|
+
}
|
|
5409
5447
|
function renderText$2(value) {
|
|
5410
5448
|
return [{
|
|
5411
5449
|
type: "text",
|
|
@@ -5431,68 +5469,71 @@ function discoverCccSubcommands(entries) {
|
|
|
5431
5469
|
}
|
|
5432
5470
|
/** 生成扩展提示(对齐 osp buildExtHint) */
|
|
5433
5471
|
function buildExtHint(hasSessionTool, hooks, subcommands) {
|
|
5434
|
-
if (!hasSessionTool) return "\n\n[CCC]
|
|
5472
|
+
if (!hasSessionTool) return "\n\n[CCC] To extend session capabilities, register a session-tool MSM (acc_msm register); see session hook-develop-guide";
|
|
5435
5473
|
const parts = [];
|
|
5436
|
-
if (hooks.length > 0) parts.push(
|
|
5437
|
-
if (subcommands.length > 0) parts.push(
|
|
5438
|
-
return `\n\n[CCC] session-tool MSM
|
|
5474
|
+
if (hooks.length > 0) parts.push(`hooks: ${hooks.join(", ")}`);
|
|
5475
|
+
if (subcommands.length > 0) parts.push(`custom subcommands (acc_msm exec session-tool): ${subcommands.join(", ")}`);
|
|
5476
|
+
return `\n\n[CCC] session-tool MSM registered${parts.length > 0 ? ` (${parts.join("; ")})` : ""}`;
|
|
5439
5477
|
}
|
|
5440
5478
|
/** hook-develop-guide 内容(对齐 osp getHookDevelopGuide) */
|
|
5441
5479
|
function getHookDevelopGuide(hasSessionTool) {
|
|
5442
5480
|
return [
|
|
5443
|
-
"═══ Session Extension Protocol (SEP) v1 —
|
|
5481
|
+
"═══ Session Extension Protocol (SEP) v1 — Developer Guide ═══",
|
|
5444
5482
|
"",
|
|
5445
|
-
"CCC
|
|
5446
|
-
"
|
|
5483
|
+
"A CCC can extend the ACC session tool by registering a session-tool MSM,",
|
|
5484
|
+
"without modifying plugin code. ACC behavior is never reduced — the CCC only",
|
|
5485
|
+
"does post-processing after ACC completes.",
|
|
5447
5486
|
"",
|
|
5448
|
-
"──
|
|
5487
|
+
"── Extension point 1: Post-processing hooks ──",
|
|
5449
5488
|
"",
|
|
5450
|
-
"ACC
|
|
5451
|
-
"
|
|
5489
|
+
"After certain ACC subcommands complete, ACC checks whether the CCC's",
|
|
5490
|
+
"session-tool MSM has registered a matching hook. If so, ACC invokes the MSM",
|
|
5491
|
+
"for post-processing.",
|
|
5452
5492
|
"",
|
|
5453
|
-
"
|
|
5493
|
+
"Available hooks:",
|
|
5454
5494
|
"",
|
|
5455
5495
|
" create-transform",
|
|
5456
|
-
"
|
|
5457
|
-
"
|
|
5458
|
-
"
|
|
5459
|
-
"
|
|
5496
|
+
" Trigger: after create writes the default SESSION.md",
|
|
5497
|
+
" Invocation: acc_msm exec session-tool --hook=create-transform --session-dir=<path>",
|
|
5498
|
+
" Allowed: read SESSION.md and modify it in place (append fields, swap templates, call APIs, etc.)",
|
|
5499
|
+
" Note: ACC has already ensured the directory and SESSION.md exist; the CCC only modifies",
|
|
5460
5500
|
"",
|
|
5461
|
-
"──
|
|
5501
|
+
"── Extension point 2: Custom subcommands ──",
|
|
5462
5502
|
"",
|
|
5463
|
-
"LLM
|
|
5464
|
-
"
|
|
5503
|
+
"The LLM can call acc_msm exec session-tool <subcommand> to run CCC-specific",
|
|
5504
|
+
"subcommands such as reindex, export, batch-create. These bypass the ACC session",
|
|
5505
|
+
"tool's enum.",
|
|
5465
5506
|
"",
|
|
5466
|
-
"──
|
|
5507
|
+
"── How to register a session-tool MSM ──",
|
|
5467
5508
|
"",
|
|
5468
|
-
"1.
|
|
5509
|
+
"1. Write the script under the CCC skills directory:",
|
|
5469
5510
|
" .opencode/skills/<ccc-name>/scripts/session-tool.ts",
|
|
5470
5511
|
"",
|
|
5471
|
-
"2.
|
|
5512
|
+
"2. Register it in mech-registry.json:",
|
|
5472
5513
|
" acc_msm register session-tool \\",
|
|
5473
5514
|
" --skill <ccc-name> --path .opencode/skills/<ccc-name>/scripts/session-tool.ts \\",
|
|
5474
5515
|
" --category semi-mech \\",
|
|
5475
|
-
" --description \"CCC session
|
|
5516
|
+
" --description \"CCC session extension: hooks + custom subcommands\" \\",
|
|
5476
5517
|
" --flags '[",
|
|
5477
5518
|
" {\"name\":\"hook\",\"type\":\"string\",\"description\":\"create-transform\"},",
|
|
5478
5519
|
" {\"name\":\"subcommand\",\"type\":\"string\",\"description\":\"reindex | export\"},",
|
|
5479
|
-
" {\"name\":\"session-dir\",\"type\":\"path\",\"description\":\"session
|
|
5480
|
-
" {\"name\":\"dry-run\",\"type\":\"boolean\",\"description\":\"
|
|
5520
|
+
" {\"name\":\"session-dir\",\"type\":\"path\",\"description\":\"session directory path\"},",
|
|
5521
|
+
" {\"name\":\"dry-run\",\"type\":\"boolean\",\"description\":\"preview mode\"}",
|
|
5481
5522
|
" ]'",
|
|
5482
5523
|
"",
|
|
5483
|
-
"3.
|
|
5484
|
-
"
|
|
5485
|
-
" ACC
|
|
5524
|
+
"3. Hook declaration convention:",
|
|
5525
|
+
" The --hook description field in flags enumerates supported hook names, split by |.",
|
|
5526
|
+
" When ACC finds create-transform in the list, it invokes it after create.",
|
|
5486
5527
|
"",
|
|
5487
|
-
"4.
|
|
5488
|
-
"
|
|
5489
|
-
" LLM
|
|
5528
|
+
"4. Subcommand declaration convention:",
|
|
5529
|
+
" The --subcommand description field in flags enumerates supported subcommand names, split by |.",
|
|
5530
|
+
" The LLM can then call acc_msm exec session-tool <subcommand>.",
|
|
5490
5531
|
"",
|
|
5491
|
-
hasSessionTool ? "✅
|
|
5532
|
+
hasSessionTool ? "✅ This CCC has a session-tool MSM registered" : "ℹ️ This CCC has no session-tool MSM yet — start with acc_msm register",
|
|
5492
5533
|
"",
|
|
5493
|
-
"──
|
|
5534
|
+
"── More information ──",
|
|
5494
5535
|
"",
|
|
5495
|
-
"
|
|
5536
|
+
"Reference ACC source: src/tools/session.ts (hook invocation logic)"
|
|
5496
5537
|
].join("\n");
|
|
5497
5538
|
}
|
|
5498
5539
|
/**
|
|
@@ -5502,37 +5543,37 @@ function getHookDevelopGuide(hasSessionTool) {
|
|
|
5502
5543
|
function createSessionTool(ctx) {
|
|
5503
5544
|
return defineTool({
|
|
5504
5545
|
name: "session",
|
|
5505
|
-
description: "
|
|
5546
|
+
description: "Full work-session lifecycle (AGENT_SESSIONS/, home-session convention). list/show/create/use/close/health/qa/archive/summary/hook-develop-guide. create requires --desc <desc> [--goal] or --issue <ticket> (exactly one); close requires --confirm; use activates the session for the current dsh conversation (in-memory + events restore, isolated per dsh session).",
|
|
5506
5547
|
parameters: {
|
|
5507
5548
|
action: {
|
|
5508
5549
|
type: "string",
|
|
5509
5550
|
enum: [...SESSION_ACTIONS],
|
|
5510
5551
|
required: true,
|
|
5511
|
-
description: "
|
|
5552
|
+
description: "Subcommand: list (status summary) / show (S### or dir name or fuzzy keyword) / create (--desc or --issue) / use (activate context, closed can be reopened) / close (requires --name + --confirm, irreversible) / health (stale/stalled/drift/ghost) / qa (fact check) / archive (archive, name defaults to batch) / summary (dashboard) / hook-develop-guide (CCC extension guide)"
|
|
5512
5553
|
},
|
|
5513
5554
|
name: {
|
|
5514
5555
|
type: "string",
|
|
5515
|
-
description: "show/use/close/archive/qa
|
|
5556
|
+
description: "show/use/close/archive/qa session identifier (S### or dir name or keyword)"
|
|
5516
5557
|
},
|
|
5517
5558
|
desc: {
|
|
5518
5559
|
type: "string",
|
|
5519
|
-
description: "create
|
|
5560
|
+
description: "create short description (any language, ≤5 words; mutually exclusive with issue)"
|
|
5520
5561
|
},
|
|
5521
5562
|
issue: {
|
|
5522
5563
|
type: "string",
|
|
5523
|
-
description: "create
|
|
5564
|
+
description: "create ticket number (e.g. apaas-26116; dir named YYYY-MM-DD--<issue>; mutually exclusive with desc)"
|
|
5524
5565
|
},
|
|
5525
5566
|
goal: {
|
|
5526
5567
|
type: "string",
|
|
5527
|
-
description: "create
|
|
5568
|
+
description: "create one-sentence goal (optional)"
|
|
5528
5569
|
},
|
|
5529
5570
|
confirm: {
|
|
5530
5571
|
type: "boolean",
|
|
5531
|
-
description: "close
|
|
5572
|
+
description: "close must be true (prevents accidental close)"
|
|
5532
5573
|
},
|
|
5533
5574
|
dryRun: {
|
|
5534
5575
|
type: "boolean",
|
|
5535
|
-
description: "create/archive
|
|
5576
|
+
description: "create/archive preview mode (no actual changes)"
|
|
5536
5577
|
}
|
|
5537
5578
|
},
|
|
5538
5579
|
output: {
|
|
@@ -5550,7 +5591,7 @@ function createSessionTool(ctx) {
|
|
|
5550
5591
|
switch (args.action) {
|
|
5551
5592
|
case "list": return listSessions(root) + extHint;
|
|
5552
5593
|
case "show":
|
|
5553
|
-
if (!args.name) throw new Error("show
|
|
5594
|
+
if (!args.name) throw new Error("show requires name (S### or directory name)");
|
|
5554
5595
|
return showSession(root, args.name) + extHint;
|
|
5555
5596
|
case "create": {
|
|
5556
5597
|
const result = createSession({
|
|
@@ -5575,25 +5616,31 @@ function createSessionTool(ctx) {
|
|
|
5575
5616
|
return message + extHint;
|
|
5576
5617
|
}
|
|
5577
5618
|
case "use": {
|
|
5578
|
-
if (!args.name) throw new Error("use
|
|
5579
|
-
const scope = agentScope$
|
|
5619
|
+
if (!args.name) throw new Error("use requires name (S### or directory name)");
|
|
5620
|
+
const scope = agentScope$2(exec);
|
|
5580
5621
|
const active = useSession(root, args.name, scope);
|
|
5581
5622
|
try {
|
|
5582
5623
|
const info = getActiveSessionInfo(scope);
|
|
5583
5624
|
if (info) {
|
|
5584
5625
|
const rename = (ctx.get?.("sessionTitle"))?.rename;
|
|
5585
5626
|
const dshSession = exec.agent?.session;
|
|
5586
|
-
if (dshSession && typeof rename === "function")
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5627
|
+
if (dshSession && typeof rename === "function") {
|
|
5628
|
+
const result = renameDshSessionOnUse({
|
|
5629
|
+
namingEnabled: readSimpleSettings().namingEnabled,
|
|
5630
|
+
sessionTitleAvailable: true
|
|
5631
|
+
}, dshSession, rename, info);
|
|
5632
|
+
if (result.ok) console.log(`[serenity-hooks] dsh 会话已重命名: ${String(dshSession.id ?? "?")} → ${result.title}`);
|
|
5633
|
+
else console.warn(`[serenity-hooks] dsh 会话重命名未执行: ${result.reason}`);
|
|
5634
|
+
} else console.warn(`[serenity-hooks] dsh 会话重命名未执行: sessionTitle 服务不可用或缺少 agent session`);
|
|
5590
5635
|
}
|
|
5591
|
-
} catch {
|
|
5636
|
+
} catch (err) {
|
|
5637
|
+
console.warn(`[serenity-hooks] dsh 会话重命名异常: ${String(err?.message ?? err)}`);
|
|
5638
|
+
}
|
|
5592
5639
|
return active;
|
|
5593
5640
|
}
|
|
5594
5641
|
case "close":
|
|
5595
|
-
if (!args.name) throw new Error("close
|
|
5596
|
-
return closeSession(root, args.name, args.confirm ?? false, agentScope$
|
|
5642
|
+
if (!args.name) throw new Error("close requires name (S### or directory name)");
|
|
5643
|
+
return closeSession(root, args.name, args.confirm ?? false, agentScope$2(exec));
|
|
5597
5644
|
case "archive": return archiveSessions(root, {
|
|
5598
5645
|
name: args.name,
|
|
5599
5646
|
dryRun: args.dryRun ?? false
|
|
@@ -5601,254 +5648,14 @@ function createSessionTool(ctx) {
|
|
|
5601
5648
|
case "health": return healthCheck(root) + extHint;
|
|
5602
5649
|
case "summary": return summarize(root) + extHint;
|
|
5603
5650
|
case "qa":
|
|
5604
|
-
if (!args.name) throw new Error("qa
|
|
5651
|
+
if (!args.name) throw new Error("qa requires name (S### or directory name)");
|
|
5605
5652
|
return qaCheck(root, args.name) + extHint;
|
|
5606
|
-
default: throw new Error(
|
|
5607
|
-
}
|
|
5608
|
-
}
|
|
5609
|
-
});
|
|
5610
|
-
}
|
|
5611
|
-
//#endregion
|
|
5612
|
-
//#region src/rebuild.ts
|
|
5613
|
-
/** 归档目录名(与 session-ops ARCHIVE_DIR_NAME 一致) */
|
|
5614
|
-
const ARCHIVE_DIR = "_archived";
|
|
5615
|
-
/**
|
|
5616
|
-
* 归档旧会话:SESSION.md 标记 completed + 目录移 _archived/(立即,跳过 grace)。
|
|
5617
|
-
* 纯逻辑(可单测);找不到会话/无 SESSION.md 抛错。
|
|
5618
|
-
* @returns 归档后的新路径(或 null = 会话已归档/不存在)
|
|
5619
|
-
*/
|
|
5620
|
-
function archiveSessionNow(root, key) {
|
|
5621
|
-
const sessionsDir = sessionsRoot(root);
|
|
5622
|
-
const archiveDir = join(sessionsDir, ARCHIVE_DIR);
|
|
5623
|
-
let target = null;
|
|
5624
|
-
try {
|
|
5625
|
-
const entries = readDirSafe(sessionsDir);
|
|
5626
|
-
for (const name of entries) {
|
|
5627
|
-
if (!name.includes("--S") && name !== key) continue;
|
|
5628
|
-
if (name === key || name.endsWith(`--${key}`) || name.includes(`--${key}--`)) {
|
|
5629
|
-
target = join(sessionsDir, name);
|
|
5630
|
-
break;
|
|
5631
|
-
}
|
|
5632
|
-
}
|
|
5633
|
-
} catch {
|
|
5634
|
-
return null;
|
|
5635
|
-
}
|
|
5636
|
-
if (target === null) for (const name of readDirSafe(sessionsDir)) {
|
|
5637
|
-
const m = name.match(/--S(\d{3,})--/);
|
|
5638
|
-
if (m && `S${m[1]}` === key) {
|
|
5639
|
-
target = join(sessionsDir, name);
|
|
5640
|
-
break;
|
|
5641
|
-
}
|
|
5642
|
-
}
|
|
5643
|
-
if (target === null) return null;
|
|
5644
|
-
const mdPath = join(target, "SESSION.md");
|
|
5645
|
-
if (existsSync(mdPath)) {
|
|
5646
|
-
let content = readFileSync(mdPath, "utf-8").replace(/\r\n/g, "\n");
|
|
5647
|
-
content = content.replace(/## 状态\n\n?- \[ \] 进行中/, "## 状态\n- [x] 已完成\n- [x] 已关闭(session_rebuild 归档)");
|
|
5648
|
-
const now = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
|
|
5649
|
-
if (!content.includes("-- 关闭")) content = content.replace(/(## 进度记录\n)/, `$1- ${now} — 关闭(session_rebuild 归档)\n`);
|
|
5650
|
-
writeFileSync(mdPath, content, "utf-8");
|
|
5651
|
-
}
|
|
5652
|
-
if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
|
|
5653
|
-
const dest = join(archiveDir, target.split(/[\\/]/).pop());
|
|
5654
|
-
renameSync(target, dest);
|
|
5655
|
-
return dest;
|
|
5656
|
-
}
|
|
5657
|
-
function readDirSafe(dir) {
|
|
5658
|
-
try {
|
|
5659
|
-
return readdirSync(dir);
|
|
5660
|
-
} catch {
|
|
5661
|
-
return [];
|
|
5662
|
-
}
|
|
5663
|
-
}
|
|
5664
|
-
/** 新会话锚点消息(重建指令 + SESSION.md 路径 + 摘要) */
|
|
5665
|
-
function buildRebuildAnchor(root, oldDirName, note) {
|
|
5666
|
-
const mdPath = join(root, "AGENT_SESSIONS", oldDirName, "SESSION.md");
|
|
5667
|
-
const lines = [
|
|
5668
|
-
"[SESSION-REBUILD] 上下文已清空重建(Ship of Theseus:会话归档,身份延续)。",
|
|
5669
|
-
`- 旧会话(已归档):AGENT_SESSIONS/_archived/${oldDirName}`,
|
|
5670
|
-
`- 完整上下文:${mdPath}`,
|
|
5671
|
-
`- 请先读取旧会话的 SESSION.md(目标/决策/进度/未解决问题),从上次进度继续。`
|
|
5672
|
-
];
|
|
5673
|
-
if (note) lines.push(`- 重建背景:${note}`);
|
|
5674
|
-
return lines.join("\n");
|
|
5675
|
-
}
|
|
5676
|
-
/**
|
|
5677
|
-
* 执行 session_rebuild(服务端逻辑):
|
|
5678
|
-
* ① 归档当前宁静号会话(LLM 经 session use 激活的那个)
|
|
5679
|
-
* ② 创建新宁静号 SESSION 目录(createSession)+ dsh agent 会话(ctx.agents.create)
|
|
5680
|
-
* ③ 返回锚点文本(调用方负责注入到新 agent)
|
|
5681
|
-
* @returns rebuild 结果;rebuild.enabled=false 或非 CCC 抛错
|
|
5682
|
-
*/
|
|
5683
|
-
async function executeRebuild(ctx, opts) {
|
|
5684
|
-
if (!readSimpleSettings().rebuildEnabled) throw new Error("session_rebuild 已禁用(rebuild.enabled=false,可在 dsh 设置面板开启)");
|
|
5685
|
-
const { root, activeDirName, note } = opts;
|
|
5686
|
-
let old = null;
|
|
5687
|
-
if (activeDirName) old = {
|
|
5688
|
-
dirName: activeDirName,
|
|
5689
|
-
archivedTo: archiveSessionNow(root, activeDirName)
|
|
5690
|
-
};
|
|
5691
|
-
const created = createSession({
|
|
5692
|
-
root,
|
|
5693
|
-
desc: "rebuild",
|
|
5694
|
-
goal: "上下文超限清空重建(session_rebuild)",
|
|
5695
|
-
dryRun: false
|
|
5696
|
-
});
|
|
5697
|
-
const parentSession = opts.parentSessionId;
|
|
5698
|
-
const sessionId = `rebuild-${randomUUID()}`;
|
|
5699
|
-
let newSession = {
|
|
5700
|
-
sessionId: String(sessionId),
|
|
5701
|
-
dirName: created.dirName
|
|
5702
|
-
};
|
|
5703
|
-
if (ctx.agents) try {
|
|
5704
|
-
const handle = await ctx.agents.create({
|
|
5705
|
-
sessionId,
|
|
5706
|
-
meta: {
|
|
5707
|
-
cwd: opts.agentCwd,
|
|
5708
|
-
origin: "subagent",
|
|
5709
|
-
...parentSession ? { parentSession } : {}
|
|
5710
|
-
},
|
|
5711
|
-
agentOptions: void 0
|
|
5712
|
-
});
|
|
5713
|
-
newSession = {
|
|
5714
|
-
sessionId: String(sessionId),
|
|
5715
|
-
dirName: created.dirName
|
|
5716
|
-
};
|
|
5717
|
-
const anchor = buildRebuildAnchor(root, created.dirName, note);
|
|
5718
|
-
handle.agent.inject({
|
|
5719
|
-
content: [{
|
|
5720
|
-
type: "text",
|
|
5721
|
-
text: anchor
|
|
5722
|
-
}],
|
|
5723
|
-
source: {
|
|
5724
|
-
kind: "plugin",
|
|
5725
|
-
plugin: "dsh-serenity-hooks"
|
|
5653
|
+
default: throw new Error(`Unknown action: ${args.action}`);
|
|
5726
5654
|
}
|
|
5727
|
-
});
|
|
5728
|
-
} catch {}
|
|
5729
|
-
return {
|
|
5730
|
-
oldSession: old,
|
|
5731
|
-
newSession,
|
|
5732
|
-
anchor: buildRebuildAnchor(root, created.dirName, note)
|
|
5733
|
-
};
|
|
5734
|
-
}
|
|
5735
|
-
//#endregion
|
|
5736
|
-
//#region src/tools/rebuild.ts
|
|
5737
|
-
/**
|
|
5738
|
-
* rebuild.ts — session_rebuild 工具定义(F2,v1.21)
|
|
5739
|
-
*
|
|
5740
|
-
* LLM 主动触发清空重建(用户决策:keeper 超阈值提示,不自动执行——防误清空)。
|
|
5741
|
-
* 执行:归档当前会话 → 创建新会话(宁静号 SESSION 目录 + dsh agent)→ 锚点注入。
|
|
5742
|
-
* 服务端逻辑在 ../rebuild.ts(executeRebuild,可单测)。
|
|
5743
|
-
*/
|
|
5744
|
-
function agentCwd$2(exec) {
|
|
5745
|
-
return exec.agent?.session?.header?.cwd ?? process.cwd();
|
|
5746
|
-
}
|
|
5747
|
-
function agentScope$2(exec) {
|
|
5748
|
-
return exec.agent?.session?.id ?? "default";
|
|
5749
|
-
}
|
|
5750
|
-
function renderText$1(value) {
|
|
5751
|
-
return [{
|
|
5752
|
-
type: "text",
|
|
5753
|
-
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
5754
|
-
}];
|
|
5755
|
-
}
|
|
5756
|
-
/** 创建 session_rebuild 工具(闭包捕获 ctx → ctx.agents.create 新会话) */
|
|
5757
|
-
function createRebuildTool(ctx) {
|
|
5758
|
-
return defineTool({
|
|
5759
|
-
name: "session_rebuild",
|
|
5760
|
-
description: "上下文超限清空重建(Ship of Theseus):归档当前宁静号会话(SESSION.md 标记 completed + 移 _archived/),创建新会话并注入重建锚点(SESSION.md 路径 + 摘要 + 重建指令)。用途:SESSION-KEEPER 提示上下文接近上限时,在任务自然停顿点主动调用。触发后请先读取旧会话 SESSION.md 从上次进度继续。",
|
|
5761
|
-
parameters: { note: {
|
|
5762
|
-
type: "string",
|
|
5763
|
-
description: "可选:给新会话的一句话背景(重建后 LLM 的起始上下文)"
|
|
5764
|
-
} },
|
|
5765
|
-
output: {
|
|
5766
|
-
schema: { type: "json" },
|
|
5767
|
-
render: (args, value) => renderText$1(value)
|
|
5768
|
-
},
|
|
5769
|
-
async execute(args, exec) {
|
|
5770
|
-
const root = findSerenityRoot(agentCwd$2(exec));
|
|
5771
|
-
if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
|
|
5772
|
-
const active = getActiveSessionInfo(agentScope$2(exec));
|
|
5773
|
-
const parentSessionId = (exec.agent?.session)?.id;
|
|
5774
|
-
const result = await executeRebuild(ctx, {
|
|
5775
|
-
root,
|
|
5776
|
-
activeDirName: active?.dirName ?? null,
|
|
5777
|
-
note: args.note,
|
|
5778
|
-
agentCwd: agentCwd$2(exec),
|
|
5779
|
-
parentSessionId
|
|
5780
|
-
});
|
|
5781
|
-
return {
|
|
5782
|
-
ok: true,
|
|
5783
|
-
oldSession: result.oldSession,
|
|
5784
|
-
newSession: result.newSession,
|
|
5785
|
-
anchor: result.anchor,
|
|
5786
|
-
instruction: "已归档旧会话并创建新会话。新会话将自动获得重建锚点;请在新会话中继续工作(可用 session use 激活它)。"
|
|
5787
|
-
};
|
|
5788
5655
|
}
|
|
5789
5656
|
});
|
|
5790
5657
|
}
|
|
5791
5658
|
//#endregion
|
|
5792
|
-
//#region src/tools/localstore.ts
|
|
5793
|
-
/**
|
|
5794
|
-
* localstore.ts — localstore 真实 DSH 工具定义(defineTool)
|
|
5795
|
-
*
|
|
5796
|
-
* ACC 标准本地凭据/配置存储(S133 设计,S134 重设计:CCC 根 localstore.json)。
|
|
5797
|
-
* 进程内注册,零 DSH 依赖逻辑在 localstore-ops.ts(可单测)。
|
|
5798
|
-
* doc 子命令输出存储规范,agent 可直接用 fs 工具(read/write)自己读写。
|
|
5799
|
-
*/
|
|
5800
|
-
const ACTIONS = [
|
|
5801
|
-
"list",
|
|
5802
|
-
"get",
|
|
5803
|
-
"set",
|
|
5804
|
-
"unset",
|
|
5805
|
-
"show",
|
|
5806
|
-
"doc"
|
|
5807
|
-
];
|
|
5808
|
-
function agentCwd$1(exec) {
|
|
5809
|
-
return exec.agent?.session?.header?.cwd ?? process.cwd();
|
|
5810
|
-
}
|
|
5811
|
-
function renderText(value) {
|
|
5812
|
-
return [{
|
|
5813
|
-
type: "text",
|
|
5814
|
-
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
5815
|
-
}];
|
|
5816
|
-
}
|
|
5817
|
-
const localstoreTool = defineTool({
|
|
5818
|
-
name: "localstore",
|
|
5819
|
-
description: "ACC 标准本地凭据/配置存储:一个工具管理两个命名空间 credential(凭据)与 config(本地偏好)。存储于 CCC 根根目录 localstore.json(JSON 格式,MSM 可直接读取)。git 策略:.opencode/serenity.json localstore.gitTrack(allow 可提交 / deny 禁提交,缺省 deny;.dsh 回退)——deny 时写入自动确保 .gitignore 含该文件(物理保证),cc_git commit 会检查拒绝。子命令:list(列 key,凭据不返回值)/ get <name>(读值)/ set <name> <value>(写)/ unset <name>(删)/ show <name>(元数据,凭据不打印值)/ doc(输出存储规范——路径/格式/key 规范/git 策略,agent 可按说明直接用 read/write 操作文件)。默认 scope=credential;config 需传 --scope config(路径为 section.key,如 loop.defaultModel)。",
|
|
5820
|
-
parameters: {
|
|
5821
|
-
action: {
|
|
5822
|
-
type: "string",
|
|
5823
|
-
enum: [...ACTIONS],
|
|
5824
|
-
required: true,
|
|
5825
|
-
description: "子命令:list/get/set/unset/show/doc"
|
|
5826
|
-
},
|
|
5827
|
-
name: {
|
|
5828
|
-
type: "string",
|
|
5829
|
-
description: "条目名(credential 用大写蛇形;config 用 section.key)"
|
|
5830
|
-
},
|
|
5831
|
-
value: {
|
|
5832
|
-
type: "string",
|
|
5833
|
-
description: "set 的值"
|
|
5834
|
-
},
|
|
5835
|
-
scope: {
|
|
5836
|
-
type: "string",
|
|
5837
|
-
enum: [...LOCALSTORE_SCOPES],
|
|
5838
|
-
description: "命名空间 credential|config(默认 credential)"
|
|
5839
|
-
}
|
|
5840
|
-
},
|
|
5841
|
-
output: {
|
|
5842
|
-
schema: { type: "json" },
|
|
5843
|
-
render: (args, value) => renderText(value)
|
|
5844
|
-
},
|
|
5845
|
-
async execute(args, exec) {
|
|
5846
|
-
const root = findSerenityRoot(agentCwd$1(exec));
|
|
5847
|
-
if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
|
|
5848
|
-
return runLocalStore(root, args);
|
|
5849
|
-
}
|
|
5850
|
-
});
|
|
5851
|
-
//#endregion
|
|
5852
5659
|
//#region src/seams/bootstrap.ts
|
|
5853
5660
|
/** 默认首请求工具集:dsp 平台 Minimal 等价核心(anchored 是 bash+str_replace_editor) */
|
|
5854
5661
|
const DEFAULT_BOOTSTRAP_TOOLS = [
|
|
@@ -6147,83 +5954,337 @@ function registerBootstrap(ctx) {
|
|
|
6147
5954
|
}, { prepend: true });
|
|
6148
5955
|
}
|
|
6149
5956
|
//#endregion
|
|
6150
|
-
//#region src/
|
|
6151
|
-
const
|
|
6152
|
-
|
|
6153
|
-
|
|
6154
|
-
str_replace_editor: 3,
|
|
6155
|
-
task: 10,
|
|
6156
|
-
read: 1,
|
|
6157
|
-
grep: 1,
|
|
6158
|
-
glob: 1,
|
|
6159
|
-
skill: 1,
|
|
6160
|
-
acc_msm: 1,
|
|
6161
|
-
cc_fs: 1
|
|
6162
|
-
};
|
|
6163
|
-
function scoreTool(toolName) {
|
|
6164
|
-
return SCORES[toolName] ?? 0;
|
|
6165
|
-
}
|
|
6166
|
-
var KeeperTracker = class {
|
|
6167
|
-
threshold;
|
|
6168
|
-
now;
|
|
6169
|
-
score = 0;
|
|
6170
|
-
lastTs = 0;
|
|
6171
|
-
counter = 0;
|
|
6172
|
-
constructor(threshold, now = Date.now) {
|
|
6173
|
-
this.threshold = threshold;
|
|
6174
|
-
this.now = now;
|
|
6175
|
-
}
|
|
6176
|
-
/** 记录一次工具调用 + 经过时间;返回是否应触发提醒 */
|
|
6177
|
-
step(toolName) {
|
|
6178
|
-
const now = this.now();
|
|
6179
|
-
if (this.lastTs > 0) this.score += Math.floor((now - this.lastTs) / 6e4);
|
|
6180
|
-
this.lastTs = now;
|
|
6181
|
-
this.score += scoreTool(toolName);
|
|
6182
|
-
return this.score >= this.threshold;
|
|
6183
|
-
}
|
|
6184
|
-
/** 生成确认码并清零积分 */
|
|
6185
|
-
ack() {
|
|
6186
|
-
this.counter += 1;
|
|
6187
|
-
this.score = 0;
|
|
6188
|
-
this.lastTs = 0;
|
|
6189
|
-
return `K${this.counter}`;
|
|
6190
|
-
}
|
|
6191
|
-
get currentScore() {
|
|
6192
|
-
return this.score;
|
|
6193
|
-
}
|
|
5957
|
+
//#region src/rebuild.ts
|
|
5958
|
+
const PLUGIN_SOURCE$2 = {
|
|
5959
|
+
kind: "plugin",
|
|
5960
|
+
plugin: "dsh-serenity-hooks"
|
|
6194
5961
|
};
|
|
6195
|
-
|
|
6196
|
-
|
|
5962
|
+
/** acknowledge 尾句(first-anchor 的确认要求——rebuild 重建后不重走确认轮,直接继续) */
|
|
5963
|
+
const ACK_SUFFIX_RE = /Please simply reply "acknowledge" — no action needed\.\s*$/;
|
|
5964
|
+
/**
|
|
5965
|
+
* 剥离 first-anchor 消息的 acknowledge 尾句(rebuild 锚点保留协议正文但不要求确认回复)。
|
|
5966
|
+
*/
|
|
5967
|
+
function stripAckSuffix(text) {
|
|
5968
|
+
return text.replace(ACK_SUFFIX_RE, "").trimEnd();
|
|
6197
5969
|
}
|
|
6198
|
-
/**
|
|
6199
|
-
|
|
6200
|
-
|
|
5970
|
+
/**
|
|
5971
|
+
* 构建重建锚点消息(v1.22.4 定稿语义 + v1.22.5 保留 first-anchor 正文):
|
|
5972
|
+
* 「[TRAJECTORY-REBUILD] + first-anchor 协议正文(去 acknowledge 尾句)+ 继续 {SESSION 名} 的工作」。
|
|
5973
|
+
* @param root - CCC 根
|
|
5974
|
+
* @param sessionName - 当前 use 的宁静号 SESSION 名(如 S142);无激活则通用指令
|
|
5975
|
+
* @param activeMdPath - 持久轨迹 SESSION.md 绝对路径(保持原位)
|
|
5976
|
+
* @param anchorMessages - first-anchor 协议消息序列(缺省 DEFAULT_ANCHOR_MESSAGES;可注入测试)
|
|
5977
|
+
*/
|
|
5978
|
+
function buildRebuildAnchor(root, sessionName, activeMdPath, anchorMessages = DEFAULT_ANCHOR_MESSAGES) {
|
|
5979
|
+
const rel = activeMdPath.startsWith(root) ? activeMdPath.slice(root.length + 1) : activeMdPath;
|
|
5980
|
+
return [
|
|
5981
|
+
"[TRAJECTORY-REBUILD] The conversation has been cleared and rebuilt (Ship of Theseus: the carrier is replaced, the trajectory continues).",
|
|
5982
|
+
"",
|
|
5983
|
+
...anchorMessages.flatMap((text) => [stripAckSuffix(text), ""]),
|
|
5984
|
+
sessionName !== "" ? `Continue the work of ${sessionName}.` : "Continue the current work.",
|
|
5985
|
+
`- Persistent trajectory (SESSION.md, unmoved): ${rel}`,
|
|
5986
|
+
`- Read that SESSION.md first (goal/decisions/progress/unresolved), then continue from the last checkpoint.`
|
|
5987
|
+
].join("\n");
|
|
6201
5988
|
}
|
|
6202
|
-
|
|
6203
|
-
|
|
5989
|
+
const pendingRebuilds = /* @__PURE__ */ new Map();
|
|
5990
|
+
/** 陈旧队列存活时长(毫秒):超过则丢弃(turn 异常结束/agent 崩溃时防残留误清空) */
|
|
5991
|
+
const PENDING_TTL_MS = 6e5;
|
|
5992
|
+
/**
|
|
5993
|
+
* 排队一次重建(v1.22.4 定稿语义第一步):
|
|
5994
|
+
* ① 门控校验(rebuild.enabled + 会话定位)
|
|
5995
|
+
* ② 构建锚点(激活会话名 + 持久轨迹路径)
|
|
5996
|
+
* ③ 写入 pending 队列——**不立即改 surface**(等 turn-stopping 执行)
|
|
5997
|
+
* @returns 排队结果;rebuild.enabled=false 或非 CCC 抛错
|
|
5998
|
+
*/
|
|
5999
|
+
async function queueRebuild(ctx, opts) {
|
|
6000
|
+
if (!readSimpleSettings().rebuildEnabled) throw new Error("session_rebuild is disabled (rebuild.enabled=false — enable it in the dsh settings panel)");
|
|
6001
|
+
const { root, note, dshSessionId } = opts;
|
|
6002
|
+
if (!ctx.sessions?.get?.(dshSessionId)) throw new Error(`Unable to locate dsh session ${dshSessionId} (session may be closed)`);
|
|
6003
|
+
const scope = dshSessionId;
|
|
6004
|
+
let sessionMdPath = null;
|
|
6005
|
+
let sessionName = "";
|
|
6204
6006
|
try {
|
|
6205
|
-
const
|
|
6206
|
-
if (
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
|
|
6007
|
+
const active = getActiveSessionInfo(scope);
|
|
6008
|
+
if (active?.mdPath) {
|
|
6009
|
+
sessionMdPath = active.mdPath;
|
|
6010
|
+
sessionName = active.sessionId ?? "";
|
|
6011
|
+
}
|
|
6012
|
+
} catch {}
|
|
6013
|
+
const mdPath = sessionMdPath ?? join(root, "AGENT_SESSIONS", "SESSION.md");
|
|
6014
|
+
const anchor = buildRebuildAnchor(root, sessionName, mdPath);
|
|
6015
|
+
pendingRebuilds.set(dshSessionId, {
|
|
6016
|
+
anchor,
|
|
6017
|
+
queuedAt: Date.now()
|
|
6018
|
+
});
|
|
6019
|
+
return {
|
|
6020
|
+
queued: true,
|
|
6021
|
+
anchor,
|
|
6022
|
+
sessionMdPath
|
|
6023
|
+
};
|
|
6216
6024
|
}
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
function
|
|
6224
|
-
const
|
|
6225
|
-
|
|
6226
|
-
|
|
6025
|
+
/**
|
|
6026
|
+
* 执行真正的 surface replace(v1.22.4 定稿语义第二步,turn-stopping 时调用):
|
|
6027
|
+
* 当前 turn 已完整结束(所有 tool/result 已 append)→ 全部节点替换为锚点消息。
|
|
6028
|
+
* 同一会话 id 原地重建——同工作区天然满足,无新/旧会话之分。
|
|
6029
|
+
* @returns 是否执行了 replace(false = 无 pending 或 surface 空)
|
|
6030
|
+
*/
|
|
6031
|
+
function performRebuild(session, pending) {
|
|
6032
|
+
const nodes = [...session.surface.nodes];
|
|
6033
|
+
if (nodes.length === 0) return false;
|
|
6034
|
+
session.append("user/message", {
|
|
6035
|
+
content: [{
|
|
6036
|
+
type: "text",
|
|
6037
|
+
text: pending.anchor
|
|
6038
|
+
}],
|
|
6039
|
+
source: { kind: "user" }
|
|
6040
|
+
}, {
|
|
6041
|
+
surfaceOp: {
|
|
6042
|
+
op: "replace",
|
|
6043
|
+
start: nodes[0],
|
|
6044
|
+
end: nodes[nodes.length - 1]
|
|
6045
|
+
},
|
|
6046
|
+
sourceEventSeqs: nodes
|
|
6047
|
+
});
|
|
6048
|
+
return true;
|
|
6049
|
+
}
|
|
6050
|
+
/**
|
|
6051
|
+
* 注册 turn-stopping 钩子(index.ts apply 调用):
|
|
6052
|
+
* agent 每轮 turn 结束前(serial)检查 pending 队列——有该会话的重建请求 → 执行清空
|
|
6053
|
+
* 并 **steer 自动继续**(v1.22.5:next-step 非空 → turn 不 break → 模型自动读取
|
|
6054
|
+
* SESSION.md 继续,无需用户手工输入;DSH 官方先例 hooks-claude-code Stop hook)。
|
|
6055
|
+
* 陈旧队列(超 TTL)丢弃防误清空;无 pending 零开销。
|
|
6056
|
+
*/
|
|
6057
|
+
function registerRebuildTurnHook(ctx) {
|
|
6058
|
+
ctx.on("agent/turn-stopping", (payload) => {
|
|
6059
|
+
const agent = payload?.agent;
|
|
6060
|
+
if (!agent) return;
|
|
6061
|
+
const id = agent.id;
|
|
6062
|
+
const pending = pendingRebuilds.get(id);
|
|
6063
|
+
if (pending === void 0) return;
|
|
6064
|
+
if (Date.now() - pending.queuedAt > PENDING_TTL_MS) {
|
|
6065
|
+
pendingRebuilds.delete(id);
|
|
6066
|
+
return;
|
|
6067
|
+
}
|
|
6068
|
+
pendingRebuilds.delete(id);
|
|
6069
|
+
try {
|
|
6070
|
+
if (performRebuild(agent.session, pending)) {
|
|
6071
|
+
agent.steer(createUserMessage({
|
|
6072
|
+
content: [{
|
|
6073
|
+
type: "text",
|
|
6074
|
+
text: "[TRAJECTORY-REBUILD] The conversation has been cleared and rebuilt. Follow the anchor instructions above now: read the persistent trajectory (SESSION.md) and continue the work automatically from the last checkpoint."
|
|
6075
|
+
}],
|
|
6076
|
+
source: PLUGIN_SOURCE$2
|
|
6077
|
+
}));
|
|
6078
|
+
console.log(`[serenity-hooks] session_rebuild executed with auto-continue (turn ${payload.turn ?? "?"} ended): ${id}`);
|
|
6079
|
+
}
|
|
6080
|
+
} catch (error) {
|
|
6081
|
+
console.warn(`[serenity-hooks] session_rebuild failed: ${String(error?.message ?? error)}`);
|
|
6082
|
+
}
|
|
6083
|
+
});
|
|
6084
|
+
}
|
|
6085
|
+
//#endregion
|
|
6086
|
+
//#region src/tools/rebuild.ts
|
|
6087
|
+
/**
|
|
6088
|
+
* rebuild.ts — session_rebuild 工具定义(轨迹跟踪器,v1.22.4 定稿:复用旧会话 + 完全清空重来)
|
|
6089
|
+
*
|
|
6090
|
+
* LLM 主动触发清空重建(用户决策:keeper 超阈值提示 [TRAJECTORY],不自动执行——防误清空)。
|
|
6091
|
+
* v1.22.4 定稿语义(用户明确):**复用当前 dsh 会话(同一 id),turn 结束时 surface
|
|
6092
|
+
* 完全清空重建**——锚点消息「继续 {宁静号 SESSION 名} 的工作」;SESSION.md 是持久轨迹
|
|
6093
|
+
* 永远原位,身份从 SESSION.md 自动延续。同一会话 id → 同工作区天然满足、无新/旧会话
|
|
6094
|
+
* 之分、无需销毁/切换/归档。
|
|
6095
|
+
* v1.22.5 增强(S142 用户需求):① turn-stopping 执行 replace 后 **steer 自动继续**
|
|
6096
|
+
* (无需用户手工输入,模型自动读 SESSION.md 接续);② 锚点消息**保留 first-anchor
|
|
6097
|
+
* 协议正文**(ACC 身份/EAP/协作协议,去 acknowledge 尾句——重建后直接干活)。
|
|
6098
|
+
* 执行时机:工具只排队(queueRebuild),`agent/turn-stopping` 钩子执行真正 replace
|
|
6099
|
+
* (turn 结束前所有 tool/result 已 append → 无孤儿;S141 INVALID_REQUEST 根治)。
|
|
6100
|
+
* 服务端逻辑在 ../rebuild.ts(queueRebuild/performRebuild/registerRebuildTurnHook)。
|
|
6101
|
+
*/
|
|
6102
|
+
function agentCwd$2(exec) {
|
|
6103
|
+
return exec.agent?.session?.header?.cwd ?? process.cwd();
|
|
6104
|
+
}
|
|
6105
|
+
function agentSessionId(exec) {
|
|
6106
|
+
return exec.agent?.session?.id ?? "";
|
|
6107
|
+
}
|
|
6108
|
+
function renderText$1(value) {
|
|
6109
|
+
return [{
|
|
6110
|
+
type: "text",
|
|
6111
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
6112
|
+
}];
|
|
6113
|
+
}
|
|
6114
|
+
/** 创建 session_rebuild 工具(闭包捕获 ctx → 排队清空重建,turn 结束时同一会话原地重来并自动继续) */
|
|
6115
|
+
function createRebuildTool(ctx) {
|
|
6116
|
+
return defineTool({
|
|
6117
|
+
name: "session_rebuild",
|
|
6118
|
+
description: "Trajectory-tracker overflow rebuild (Ship of Theseus): this session is the rebuildable carrier of a trajectory — the current conversation is discarded and rebuilt in place at the end of this turn with the anchor \"continue the work of {SESSION name}\" (first-anchor protocol body included), then auto-continues. SESSION.md (the trajectory's persistent body) stays in place; identity continues from it. Use when you receive a [TRAJECTORY] reminder (context above threshold), at a natural pause point. After triggering, resume from SESSION.md when this turn ends.",
|
|
6119
|
+
parameters: { note: {
|
|
6120
|
+
type: "string",
|
|
6121
|
+
description: "Optional: one-sentence rebuild background note (for the rebuilt self)"
|
|
6122
|
+
} },
|
|
6123
|
+
output: {
|
|
6124
|
+
schema: { type: "json" },
|
|
6125
|
+
render: (args, value) => renderText$1(value)
|
|
6126
|
+
},
|
|
6127
|
+
async execute(args, exec) {
|
|
6128
|
+
const root = findSerenityRoot(agentCwd$2(exec));
|
|
6129
|
+
if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
|
|
6130
|
+
const dshSessionId = agentSessionId(exec);
|
|
6131
|
+
if (!dshSessionId) throw new Error("Unable to determine the current dsh session id");
|
|
6132
|
+
const result = await queueRebuild(ctx, {
|
|
6133
|
+
root,
|
|
6134
|
+
note: args.note,
|
|
6135
|
+
agentCwd: agentCwd$2(exec),
|
|
6136
|
+
dshSessionId
|
|
6137
|
+
});
|
|
6138
|
+
return {
|
|
6139
|
+
ok: true,
|
|
6140
|
+
queued: result.queued,
|
|
6141
|
+
anchor: result.anchor,
|
|
6142
|
+
sessionMdPath: result.sessionMdPath,
|
|
6143
|
+
instruction: "Queued clear-and-rebuild: when this turn ends, the same conversation will be cleared and injected with the \"continue the work\" anchor (first-anchor protocol body included), then auto-continue — no manual input needed; resume from SESSION.md at that point."
|
|
6144
|
+
};
|
|
6145
|
+
}
|
|
6146
|
+
});
|
|
6147
|
+
}
|
|
6148
|
+
//#endregion
|
|
6149
|
+
//#region src/tools/localstore.ts
|
|
6150
|
+
/**
|
|
6151
|
+
* localstore.ts — localstore 真实 DSH 工具定义(defineTool)
|
|
6152
|
+
*
|
|
6153
|
+
* ACC 标准本地凭据/配置存储(S133 设计,S134 重设计:CCC 根 localstore.json)。
|
|
6154
|
+
* 进程内注册,零 DSH 依赖逻辑在 localstore-ops.ts(可单测)。
|
|
6155
|
+
* doc 子命令输出存储规范,agent 可直接用 fs 工具(read/write)自己读写。
|
|
6156
|
+
*/
|
|
6157
|
+
const ACTIONS = [
|
|
6158
|
+
"list",
|
|
6159
|
+
"get",
|
|
6160
|
+
"set",
|
|
6161
|
+
"unset",
|
|
6162
|
+
"show",
|
|
6163
|
+
"doc"
|
|
6164
|
+
];
|
|
6165
|
+
function agentCwd$1(exec) {
|
|
6166
|
+
return exec.agent?.session?.header?.cwd ?? process.cwd();
|
|
6167
|
+
}
|
|
6168
|
+
function renderText(value) {
|
|
6169
|
+
return [{
|
|
6170
|
+
type: "text",
|
|
6171
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
6172
|
+
}];
|
|
6173
|
+
}
|
|
6174
|
+
const localstoreTool = defineTool({
|
|
6175
|
+
name: "localstore",
|
|
6176
|
+
description: "ACC standard local credential/config storage: one tool manages two namespaces — credential (credentials) and config (local preferences). Stored in localstore.json at the CCC root (JSON format, directly readable by MSMs). Git policy: .opencode/serenity.json localstore.gitTrack (allow commits / deny commits, default deny; .dsh fallback) — when deny, writes ensure .gitignore contains this file (physical guarantee), cc_git commit checks and refuses. Subcommands: list (list keys, credentials never return values) / get <name> (read value) / set <name> <value> (write) / unset <name> (delete) / show <name> (metadata, credentials never print values) / doc (output the storage spec — path/format/key conventions/git policy; agents may operate the file directly with read/write per the spec). Default scope=credential; config requires --scope config (path is section.key, e.g. loop.defaultModel).",
|
|
6177
|
+
parameters: {
|
|
6178
|
+
action: {
|
|
6179
|
+
type: "string",
|
|
6180
|
+
enum: [...ACTIONS],
|
|
6181
|
+
required: true,
|
|
6182
|
+
description: "Subcommand: list/get/set/unset/show/doc"
|
|
6183
|
+
},
|
|
6184
|
+
name: {
|
|
6185
|
+
type: "string",
|
|
6186
|
+
description: "Entry name (credentials: UPPER_SNAKE; config: section.key)"
|
|
6187
|
+
},
|
|
6188
|
+
value: {
|
|
6189
|
+
type: "string",
|
|
6190
|
+
description: "set value"
|
|
6191
|
+
},
|
|
6192
|
+
scope: {
|
|
6193
|
+
type: "string",
|
|
6194
|
+
enum: [...LOCALSTORE_SCOPES],
|
|
6195
|
+
description: "Namespace credential|config (default credential)"
|
|
6196
|
+
}
|
|
6197
|
+
},
|
|
6198
|
+
output: {
|
|
6199
|
+
schema: { type: "json" },
|
|
6200
|
+
render: (args, value) => renderText(value)
|
|
6201
|
+
},
|
|
6202
|
+
async execute(args, exec) {
|
|
6203
|
+
const root = findSerenityRoot(agentCwd$1(exec));
|
|
6204
|
+
if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
|
|
6205
|
+
return runLocalStore(root, args);
|
|
6206
|
+
}
|
|
6207
|
+
});
|
|
6208
|
+
//#endregion
|
|
6209
|
+
//#region src/seams/keeper.ts
|
|
6210
|
+
const SCORES = {
|
|
6211
|
+
write: 3,
|
|
6212
|
+
edit: 3,
|
|
6213
|
+
str_replace_editor: 3,
|
|
6214
|
+
task: 10,
|
|
6215
|
+
read: 1,
|
|
6216
|
+
grep: 1,
|
|
6217
|
+
glob: 1,
|
|
6218
|
+
skill: 1,
|
|
6219
|
+
acc_msm: 1,
|
|
6220
|
+
cc_fs: 1
|
|
6221
|
+
};
|
|
6222
|
+
function scoreTool(toolName) {
|
|
6223
|
+
return SCORES[toolName] ?? 0;
|
|
6224
|
+
}
|
|
6225
|
+
var KeeperTracker = class {
|
|
6226
|
+
threshold;
|
|
6227
|
+
now;
|
|
6228
|
+
score = 0;
|
|
6229
|
+
lastTs = 0;
|
|
6230
|
+
counter = 0;
|
|
6231
|
+
constructor(threshold, now = Date.now) {
|
|
6232
|
+
this.threshold = threshold;
|
|
6233
|
+
this.now = now;
|
|
6234
|
+
}
|
|
6235
|
+
/** 记录一次工具调用 + 经过时间;返回是否应触发提醒 */
|
|
6236
|
+
step(toolName) {
|
|
6237
|
+
const now = this.now();
|
|
6238
|
+
if (this.lastTs > 0) this.score += Math.floor((now - this.lastTs) / 6e4);
|
|
6239
|
+
this.lastTs = now;
|
|
6240
|
+
this.score += scoreTool(toolName);
|
|
6241
|
+
return this.score >= this.threshold;
|
|
6242
|
+
}
|
|
6243
|
+
/** 生成确认码并清零积分 */
|
|
6244
|
+
ack() {
|
|
6245
|
+
this.counter += 1;
|
|
6246
|
+
this.score = 0;
|
|
6247
|
+
this.lastTs = 0;
|
|
6248
|
+
return `K${this.counter}`;
|
|
6249
|
+
}
|
|
6250
|
+
get currentScore() {
|
|
6251
|
+
return this.score;
|
|
6252
|
+
}
|
|
6253
|
+
};
|
|
6254
|
+
function reminderText(code, score) {
|
|
6255
|
+
return `[TRAJECTORY-STEWARD] Score threshold reached (${score}). Please acknowledge with [TRAJECTORY-STEWARD-recorded-${code}] once progress is synced to the working session (acc-session show). No need to interrupt your work — just acknowledge inline and keep going.`;
|
|
6256
|
+
}
|
|
6257
|
+
/** F2 rebuild 提示(v1.21;v1.22.1 对齐"轨迹跟踪器"概念;v1.23.0 英化 + 载体关系):
|
|
6258
|
+
* SESSION.md = trajectory 持久身体,自身会话 = 可重建载体(工作副本)。
|
|
6259
|
+
* 上下文接近上限时引导 LLM 主动触发 session_rebuild。 */
|
|
6260
|
+
function rebuildReminderText(ratio) {
|
|
6261
|
+
return `[TRAJECTORY] Context usage at ${(ratio * 100).toFixed(0)}% (trajectory-tracker threshold). This session is the rebuildable carrier of the trajectory: SESSION.md is the persistent body, this conversation is only a temporary work copy — no compaction needed. Call session_rebuild at a natural pause to rebuild the carrier (this copy is discarded; identity continues from SESSION.md).`;
|
|
6262
|
+
}
|
|
6263
|
+
/** 读取会话 contextPressure 投影(sessionProjections 可选服务;未装配返回 null) */
|
|
6264
|
+
function readContextPressure(ctx, session) {
|
|
6265
|
+
try {
|
|
6266
|
+
const projections = ctx.get?.("sessionProjections");
|
|
6267
|
+
if (!projections) return null;
|
|
6268
|
+
const pressure = (projections.snapshot?.(session))?.values?.contextPressure;
|
|
6269
|
+
if (!pressure || typeof pressure.projectedTokens !== "number") return null;
|
|
6270
|
+
return {
|
|
6271
|
+
projectedTokens: pressure.projectedTokens,
|
|
6272
|
+
contextWindow: pressure.contextWindow
|
|
6273
|
+
};
|
|
6274
|
+
} catch {
|
|
6275
|
+
return null;
|
|
6276
|
+
}
|
|
6277
|
+
}
|
|
6278
|
+
const PLUGIN_SOURCE$1 = {
|
|
6279
|
+
kind: "plugin",
|
|
6280
|
+
plugin: "dsh-serenity-hooks"
|
|
6281
|
+
};
|
|
6282
|
+
/** 每 agent 一个跟踪器(进程内存态,agent token 维度) */
|
|
6283
|
+
const trackers = /* @__PURE__ */ new Map();
|
|
6284
|
+
function registerKeeper(ctx, opts = {}) {
|
|
6285
|
+
const defaultThreshold = opts.defaultThreshold ?? 150;
|
|
6286
|
+
const trackerFor = (exec) => {
|
|
6287
|
+
const key = exec.agent?.session?.id ?? "global";
|
|
6227
6288
|
let t = trackers.get(key);
|
|
6228
6289
|
if (!t) {
|
|
6229
6290
|
const root = findSerenityRoot(exec.agent?.session?.header?.cwd ?? process.cwd());
|
|
@@ -6238,32 +6299,30 @@ function registerKeeper(ctx, opts = {}) {
|
|
|
6238
6299
|
const tracker = trackerFor(exec);
|
|
6239
6300
|
const shouldRemind = tracker.step(exec.name);
|
|
6240
6301
|
const downstream = await next();
|
|
6241
|
-
|
|
6242
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
|
|
6248
|
-
|
|
6249
|
-
});
|
|
6250
|
-
const extra = [];
|
|
6302
|
+
const blocks = [];
|
|
6303
|
+
if (shouldRemind) {
|
|
6304
|
+
const code = tracker.ack();
|
|
6305
|
+
blocks.push({
|
|
6306
|
+
type: "text",
|
|
6307
|
+
text: reminderText(code, tracker.currentScore)
|
|
6308
|
+
});
|
|
6309
|
+
}
|
|
6251
6310
|
if (readSimpleSettings().rebuildEnabled) {
|
|
6252
6311
|
const session = exec.agent?.session;
|
|
6253
6312
|
if (session) {
|
|
6254
6313
|
const pressure = readContextPressure(ctx, session);
|
|
6255
6314
|
if (pressure && pressure.contextWindow && pressure.contextWindow > 0) {
|
|
6256
6315
|
const ratio = pressure.projectedTokens / pressure.contextWindow;
|
|
6257
|
-
if (ratio >= readSimpleSettings().rebuildThreshold)
|
|
6316
|
+
if (ratio >= readSimpleSettings().rebuildThreshold) blocks.push({
|
|
6258
6317
|
type: "text",
|
|
6259
6318
|
text: rebuildReminderText(ratio)
|
|
6260
6319
|
});
|
|
6261
6320
|
}
|
|
6262
6321
|
}
|
|
6263
6322
|
}
|
|
6264
|
-
|
|
6323
|
+
if (blocks.length === 0) return downstream;
|
|
6265
6324
|
const reminderAll = createUserMessage({
|
|
6266
|
-
content:
|
|
6325
|
+
content: blocks,
|
|
6267
6326
|
source: PLUGIN_SOURCE$1
|
|
6268
6327
|
});
|
|
6269
6328
|
if (downstream.kind === "block") return {
|
|
@@ -6348,7 +6407,7 @@ function findEntrySkills(root) {
|
|
|
6348
6407
|
}
|
|
6349
6408
|
function truncateContent(content, maxChars) {
|
|
6350
6409
|
if (content.length <= maxChars) return content;
|
|
6351
|
-
return content.slice(0, maxChars) + `\n
|
|
6410
|
+
return content.slice(0, maxChars) + `\n... (truncated, original length ${content.length} chars)`;
|
|
6352
6411
|
}
|
|
6353
6412
|
//#endregion
|
|
6354
6413
|
//#region src/seams/system-prompt.ts
|
|
@@ -6357,7 +6416,7 @@ const HIDDEN_LINES = /安全模式|safe-mode|\.serenity-safe-on/g;
|
|
|
6357
6416
|
function sanitizeSkillContent(content) {
|
|
6358
6417
|
return content.split("\n").filter((line) => !HIDDEN_LINES.test(line)).join("\n");
|
|
6359
6418
|
}
|
|
6360
|
-
/** 1) ACC 块:身份 + CCC 名/Root + 内置工具清单(工具名换本插件真实
|
|
6419
|
+
/** 1) ACC 块:身份 + CCC 名/Root + 内置工具清单(工具名换本插件真实 11 工具) */
|
|
6361
6420
|
function accBlock(root) {
|
|
6362
6421
|
const cccName = basename(root);
|
|
6363
6422
|
return [
|
|
@@ -6370,18 +6429,19 @@ function accBlock(root) {
|
|
|
6370
6429
|
"the runtime instance of an Abstract Cognitive Container (ACC).",
|
|
6371
6430
|
"The ACC (this plugin) provides the following built-in tools:",
|
|
6372
6431
|
"",
|
|
6373
|
-
" cc_fs
|
|
6374
|
-
" session
|
|
6375
|
-
" acc_kit
|
|
6376
|
-
" cc_git
|
|
6377
|
-
" acc_msm
|
|
6378
|
-
" eap
|
|
6379
|
-
" neat
|
|
6380
|
-
" cce
|
|
6381
|
-
" loop
|
|
6382
|
-
"
|
|
6432
|
+
" cc_fs — CCC filesystem operations (root/resolve/exists/list/tree/relative/mkdir/rm/mv/cp/touch/append/reveal/info/find)",
|
|
6433
|
+
" session — session lifecycle (list/show/create/health/qa/archive/summary)",
|
|
6434
|
+
" acc_kit — ACC utility kit (health: CCC three principles / time: now / wait: wait N seconds)",
|
|
6435
|
+
" cc_git — git operations (status/commit/push/log)",
|
|
6436
|
+
" acc_msm — MSM framework (list/exec/register/deregister/check/guide)",
|
|
6437
|
+
" eap — return the full EAP cognitive quality framework",
|
|
6438
|
+
" neat — return the full Neat design collaboration protocol",
|
|
6439
|
+
" cce — return the full Cognitive Continuity Engineering framework",
|
|
6440
|
+
" loop — run a dedicated model-specific agent in repeated rounds toward a goal",
|
|
6441
|
+
" session_rebuild — rebuild this conversation in place from SESSION.md when the trajectory-tracker trips",
|
|
6442
|
+
" localstore — ACC local credential/config storage (CCC-root localstore.json, JSON format; git policy localstore.gitTrack default deny); doc subcommand outputs the spec",
|
|
6383
6443
|
"",
|
|
6384
|
-
" ℹ️ CCC
|
|
6444
|
+
" ℹ️ Use relative paths from the CCC root for CCC-internal file operations (read/write/edit/glob/grep etc.), e.g. AGENT_SESSIONS/2026-08-14--S134--x/SESSION.md; Root / absolute SESSION.md paths are identifiers only, not tool arguments",
|
|
6385
6445
|
"",
|
|
6386
6446
|
"The DSH platform tools remain available too (read/write/edit/glob/grep/web_search/ask_user_question/subagent/workflow/goal and more) — the ACC tools above are the serenity-native layer, not the only tools.",
|
|
6387
6447
|
"",
|
|
@@ -6454,6 +6514,12 @@ function principlesBlock(root) {
|
|
|
6454
6514
|
"fault to be hidden. Never disguise or excuse what you do not know;",
|
|
6455
6515
|
"not-knowing is a state to be repaired, and reporting it is the first repair.",
|
|
6456
6516
|
"",
|
|
6517
|
+
"The session-trajectory relation: a session is the rebuildable carrier of a",
|
|
6518
|
+
"trajectory. SESSION.md is the trajectory's persistent body — it never moves;",
|
|
6519
|
+
"the current conversation is a temporary work copy that may be discarded and",
|
|
6520
|
+
"rebuilt (session_rebuild). Identity belongs to the trajectory, not to any",
|
|
6521
|
+
"session.",
|
|
6522
|
+
"",
|
|
6457
6523
|
"MSM principles — machinery before improvisation:",
|
|
6458
6524
|
"- Determinism first: use a registered Mech before hand-rolling; reserve",
|
|
6459
6525
|
" Semi-Mech for genuine judgment points.",
|
|
@@ -6479,10 +6545,15 @@ function eapBlock() {
|
|
|
6479
6545
|
return [
|
|
6480
6546
|
"",
|
|
6481
6547
|
"=== Serenity EAP ===",
|
|
6482
|
-
"
|
|
6483
|
-
"
|
|
6484
|
-
" •
|
|
6485
|
-
"
|
|
6548
|
+
"Self-check before every output (Explicit Abstraction Principle: the functional",
|
|
6549
|
+
"value of a thought equals its external reconstructability):",
|
|
6550
|
+
" • E↑ Explicit — variables/entities clearly defined, relationships with",
|
|
6551
|
+
" direction/cardinality, boundaries drawn; avoid ambiguous words (\"handle\",",
|
|
6552
|
+
" \"optimize\" → be specific)",
|
|
6553
|
+
" • R↓ Reconstructable — key decisions record rationale and alternatives;",
|
|
6554
|
+
" no level-skipping (align the upper layer before descending)",
|
|
6555
|
+
" • S↑ Stable — structures regenerate repeatably, no reliance on implicit",
|
|
6556
|
+
" context",
|
|
6486
6557
|
""
|
|
6487
6558
|
].join("\n");
|
|
6488
6559
|
}
|
|
@@ -6538,9 +6609,11 @@ function metaphorBlock() {
|
|
|
6538
6609
|
" ballast (constraints) before setting sail. Verdict: skipping the",
|
|
6539
6610
|
" anchor and working directly = sailing uninspected.",
|
|
6540
6611
|
"",
|
|
6541
|
-
"7. The Logbook → Session Tracking. SESSION.md is the
|
|
6542
|
-
"
|
|
6543
|
-
"
|
|
6612
|
+
"7. The Logbook → Session Tracking. SESSION.md is the trajectory's logbook —",
|
|
6613
|
+
" the persistent body of the voyage; sessions are rebuildable carriers of",
|
|
6614
|
+
" the trajectory. Discard the carrier, keep the logbook. Unrecorded =",
|
|
6615
|
+
" unvoyaged. Verdict: finishing multi-step work without a progress record",
|
|
6616
|
+
" = a missing page.",
|
|
6544
6617
|
"",
|
|
6545
6618
|
"8. The Ship of Theseus → Continuity. Planks may be replaced; the ship",
|
|
6546
6619
|
" remains the same. The container can be rebuilt; identity persists.",
|
|
@@ -6651,8 +6724,8 @@ function sessionBlock(root, scope = DEFAULT_SESSION_SCOPE) {
|
|
|
6651
6724
|
return [
|
|
6652
6725
|
"",
|
|
6653
6726
|
"=== Serenity Session ===",
|
|
6654
|
-
`Active session: ${active.sessionId} — ${active.dirName}`,
|
|
6655
|
-
`SESSION.md path: ${active.mdPath}`,
|
|
6727
|
+
`Active session: ${active.sessionId} — ${active.dirName} (this session is the rebuildable carrier of the trajectory)`,
|
|
6728
|
+
`SESSION.md path: ${active.mdPath} (the trajectory's persistent body — stays in place through rebuilds)`,
|
|
6656
6729
|
"",
|
|
6657
6730
|
"Rules:",
|
|
6658
6731
|
" • Record all progress into this SESSION.md",
|
|
@@ -6669,6 +6742,15 @@ function sessionBlock(root, scope = DEFAULT_SESSION_SCOPE) {
|
|
|
6669
6742
|
" status: \"completed\" }",
|
|
6670
6743
|
"This preserves the session context across todo updates.",
|
|
6671
6744
|
"Do NOT remove or reorder this item — keep it at position 0.",
|
|
6745
|
+
"",
|
|
6746
|
+
"TRAJECTORY-STEWARD: a background tracker scores your tool use (write/edit=3,",
|
|
6747
|
+
"task=10, read/grep/glob/msm=1, +1 per minute) and reminds you with a",
|
|
6748
|
+
"[TRAJECTORY-STEWARD] message when the threshold is reached. On every such",
|
|
6749
|
+
"reminder you MUST reply with the exact ACK code:",
|
|
6750
|
+
" [TRAJECTORY-STEWARD-recorded-{code}] — if you recorded progress to SESSION.md",
|
|
6751
|
+
" [TRAJECTORY-STEWARD-skipped-{code}] — if nothing to record this round",
|
|
6752
|
+
"Do not ignore the reminder; do not stop ongoing work. Codes are single-use;",
|
|
6753
|
+
"never reuse a prior code.",
|
|
6672
6754
|
""
|
|
6673
6755
|
].join("\n");
|
|
6674
6756
|
}
|
|
@@ -6713,9 +6795,9 @@ function codeModeAdaptationLine(ctx, scope) {
|
|
|
6713
6795
|
return [
|
|
6714
6796
|
"",
|
|
6715
6797
|
"=== Serenity Code Mode ===",
|
|
6716
|
-
"
|
|
6717
|
-
"
|
|
6718
|
-
"
|
|
6798
|
+
"This session renders tools in Code Mode: direct calls to ACC tool names (cc_fs/acc_msm etc.) are rejected (UNKNOWN_TOOL).",
|
|
6799
|
+
"Call them inside a single run_code program via the generated SDK bindings: `await tools.cc_fs(...)`, `await tools.acc_msm(...)`.",
|
|
6800
|
+
"The program only returns what you print/return — curate your output.",
|
|
6719
6801
|
""
|
|
6720
6802
|
].join("\n");
|
|
6721
6803
|
}
|
|
@@ -6797,14 +6879,14 @@ function accIdentityText(root, configPaths = DEFAULT_SERENITY_CONFIG_PATHS, entr
|
|
|
6797
6879
|
const loop = loadSerenityConfig(root, configPaths).loop?.defaultModel;
|
|
6798
6880
|
const phase2 = existsSync(resolve(root, ".dsh", "PHASE2-PROMPT.md"));
|
|
6799
6881
|
const lines = [
|
|
6800
|
-
`[ACC]
|
|
6801
|
-
`- CCC
|
|
6802
|
-
`-
|
|
6803
|
-
`-
|
|
6882
|
+
`[ACC] Serenity cognitive container active (dsh-serenity-hooks v${ACC_VERSION})`,
|
|
6883
|
+
`- CCC root: ${root}`,
|
|
6884
|
+
`- Constraints: path isolation (P3, fs sandbox) + session tracking (AGENT_SESSIONS/)`,
|
|
6885
|
+
`- Knowledge: load the acc-serenity entry skill; use acc-eap / acc-neat for design collaboration`
|
|
6804
6886
|
];
|
|
6805
|
-
if (loop) lines.push(`- loop
|
|
6887
|
+
if (loop) lines.push(`- loop default model: ${loop}`);
|
|
6806
6888
|
if (phase2) {
|
|
6807
|
-
lines.push("- ⚠️ **Phase 2
|
|
6889
|
+
lines.push("- ⚠️ **Phase 2 cognitive alignment interview pending**: work through the 5 Topics below and record answers in an AGENT_SESSIONS/ session");
|
|
6808
6890
|
try {
|
|
6809
6891
|
const prompt = readFileSync(resolve(root, ".dsh", "PHASE2-PROMPT.md"), "utf-8");
|
|
6810
6892
|
lines.push(truncateContent(prompt, Math.min(entrySkillMaxChars, 8e3)));
|
|
@@ -6938,16 +7020,65 @@ function registerCompactRetention(ctx, opts = {}) {
|
|
|
6938
7020
|
} catch {}
|
|
6939
7021
|
});
|
|
6940
7022
|
}
|
|
7023
|
+
const B32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
7024
|
+
/** base32(无填充,大小写不敏感)→ 字节;非法字符抛错。 */
|
|
7025
|
+
function base32Decode(input) {
|
|
7026
|
+
const clean = input.toUpperCase().replace(/[\s=]/g, "");
|
|
7027
|
+
if (clean.length === 0) throw new Error("empty base32");
|
|
7028
|
+
const out = [];
|
|
7029
|
+
let bits = 0;
|
|
7030
|
+
let value = 0;
|
|
7031
|
+
for (const ch of clean) {
|
|
7032
|
+
const idx = B32_ALPHABET.indexOf(ch);
|
|
7033
|
+
if (idx === -1) throw new Error(`invalid base32 char: ${ch}`);
|
|
7034
|
+
value = value << 5 | idx;
|
|
7035
|
+
bits += 5;
|
|
7036
|
+
if (bits >= 8) {
|
|
7037
|
+
out.push(value >>> bits - 8 & 255);
|
|
7038
|
+
bits -= 8;
|
|
7039
|
+
}
|
|
7040
|
+
}
|
|
7041
|
+
return Uint8Array.from(out);
|
|
7042
|
+
}
|
|
7043
|
+
/** 一个时步的 TOTP code(6 位,前导零保留)。counter = floor(epochSeconds / step) */
|
|
7044
|
+
function totpCode(secretBase32, counter) {
|
|
7045
|
+
const key = base32Decode(secretBase32);
|
|
7046
|
+
const msg = Buffer.alloc(8);
|
|
7047
|
+
msg.writeBigUInt64BE(BigInt(counter));
|
|
7048
|
+
const digest = createHmac("sha1", Buffer.from(key)).update(msg).digest();
|
|
7049
|
+
const offset = digest[digest.length - 1] & 15;
|
|
7050
|
+
const bin = (digest[offset] & 127) << 24 | digest[offset + 1] << 16 | digest[offset + 2] << 8 | digest[offset + 3];
|
|
7051
|
+
return String(bin % 10 ** 6).padStart(6, "0");
|
|
7052
|
+
}
|
|
7053
|
+
/** 当前 epoch 秒 */
|
|
7054
|
+
function nowEpochSeconds() {
|
|
7055
|
+
return Math.floor(Date.now() / 1e3);
|
|
7056
|
+
}
|
|
7057
|
+
/**
|
|
7058
|
+
* 校验用户输入的 code(允许 ±TOTP_WINDOW 时步漂移,防重放窗口内同 code 复用由
|
|
7059
|
+
* 调用方按账号记录最近成功 counter 实现——本函数只做纯算法校验)。
|
|
7060
|
+
* @returns 命中的 counter(用于防重放);不匹配返回 null。
|
|
7061
|
+
*/
|
|
7062
|
+
function verifyTotpCode(secretBase32, code, nowSeconds = nowEpochSeconds()) {
|
|
7063
|
+
if (!/^\d{6}$/.test(code)) return null;
|
|
7064
|
+
const current = Math.floor(nowSeconds / 30);
|
|
7065
|
+
for (let offset = -1; offset <= 1; offset++) if (totpCode(secretBase32, current + offset) === code) return current + offset;
|
|
7066
|
+
return null;
|
|
7067
|
+
}
|
|
6941
7068
|
//#endregion
|
|
6942
7069
|
//#region src/config-ops.ts
|
|
6943
7070
|
/**
|
|
6944
|
-
* config-ops.ts —
|
|
7071
|
+
* config-ops.ts — plugin 全局配置读写(结构化,`~/.dsh/serenity-hooks.json`)
|
|
6945
7072
|
*
|
|
6946
|
-
*
|
|
6947
|
-
*
|
|
6948
|
-
* localstore.json
|
|
7073
|
+
* 归属原则(S142 用户拍板,v1.22):**plugin 是全局的,CCC 是具体的**——
|
|
7074
|
+
* 账号密码/gateway 监听配置是 plugin 级能力,归 plugin 全局文件;
|
|
7075
|
+
* CCC 的 localstore.json 只管 CCC 自己的凭据/配置。v1.21.x 曾把
|
|
7076
|
+
* `serenityAdvanced` 存进 CCC localstore(归属错误 + 与 DSH settings 开关割裂),
|
|
7077
|
+
* 本版本迁移到 plugin 全局文件(migrateLegacyLocalstore 一次性迁移)。
|
|
6949
7078
|
*
|
|
6950
|
-
*
|
|
7079
|
+
* 文件:$DSH_HOME/serenity-hooks.json(缺省 ~/.dsh/serenity-hooks.json;
|
|
7080
|
+
* env SERENITY_HOOKS_CONFIG 可覆盖——测试/部署注入)。
|
|
7081
|
+
* 权限:0600(含账号密码 hash,敏感)。
|
|
6951
7082
|
*
|
|
6952
7083
|
* 安全:密码仅存 scrypt hash(node:crypto 内置,零依赖);wire 层永不返回 hash
|
|
6953
7084
|
* (GET 只回 user/id,设置面板"密码"字段提交空串 = 不修改)。
|
|
@@ -6961,7 +7092,11 @@ function defaultAdvancedSettings() {
|
|
|
6961
7092
|
enabled: false,
|
|
6962
7093
|
host: "0.0.0.0",
|
|
6963
7094
|
port: 3081,
|
|
6964
|
-
accounts: []
|
|
7095
|
+
accounts: [],
|
|
7096
|
+
workspaces: [],
|
|
7097
|
+
cookieSecure: false,
|
|
7098
|
+
allowWorkspaceCreate: true,
|
|
7099
|
+
totpEnabled: false
|
|
6965
7100
|
},
|
|
6966
7101
|
rebuild: {
|
|
6967
7102
|
enabled: true,
|
|
@@ -6986,8 +7121,14 @@ function verifyPassword(password, stored) {
|
|
|
6986
7121
|
const derived = scryptSync(password, salt, SCRYPT_KEYLEN);
|
|
6987
7122
|
return expected.length === derived.length && timingSafeEqual(expected, derived);
|
|
6988
7123
|
}
|
|
6989
|
-
|
|
6990
|
-
|
|
7124
|
+
/** 全局配置文件路径:env SERENITY_HOOKS_CONFIG 覆盖(测试注入)→ $DSH_HOME → ~/.dsh */
|
|
7125
|
+
function globalConfigPath() {
|
|
7126
|
+
const override = process.env.SERENITY_HOOKS_CONFIG;
|
|
7127
|
+
if (override && override !== "") return override;
|
|
7128
|
+
const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? "", ".dsh");
|
|
7129
|
+
return join(dshHome, "serenity-hooks.json");
|
|
7130
|
+
}
|
|
7131
|
+
function readFileSafe(p) {
|
|
6991
7132
|
if (!existsSync(p)) return {};
|
|
6992
7133
|
try {
|
|
6993
7134
|
const v = JSON.parse(readFileSync(p, "utf-8").replace(/^\uFEFF/, ""));
|
|
@@ -6997,9 +7138,11 @@ function readAll(root) {
|
|
|
6997
7138
|
return {};
|
|
6998
7139
|
}
|
|
6999
7140
|
}
|
|
7000
|
-
function
|
|
7001
|
-
writeFileSync(
|
|
7002
|
-
|
|
7141
|
+
function writeFileSafe(p, data) {
|
|
7142
|
+
writeFileSync(p, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
7143
|
+
try {
|
|
7144
|
+
chmodSync(p, 384);
|
|
7145
|
+
} catch {}
|
|
7003
7146
|
}
|
|
7004
7147
|
/** 深合并默认值(缺省字段补齐;accounts 数组整体替换) */
|
|
7005
7148
|
function mergeWithDefaults(raw) {
|
|
@@ -7014,7 +7157,11 @@ function mergeWithDefaults(raw) {
|
|
|
7014
7157
|
enabled: typeof gateway.enabled === "boolean" ? gateway.enabled : def.gateway.enabled,
|
|
7015
7158
|
host: typeof gateway.host === "string" && gateway.host !== "" ? gateway.host : def.gateway.host,
|
|
7016
7159
|
port: typeof gateway.port === "number" && Number.isInteger(gateway.port) ? gateway.port : def.gateway.port,
|
|
7017
|
-
accounts: Array.isArray(gateway.accounts) ? gateway.accounts.filter((a) => typeof a === "object" && a !== null && typeof a.id === "string" && typeof a.user === "string" && typeof a.passHash === "string") : def.gateway.accounts
|
|
7160
|
+
accounts: Array.isArray(gateway.accounts) ? gateway.accounts.filter((a) => typeof a === "object" && a !== null && typeof a.id === "string" && typeof a.user === "string" && typeof a.passHash === "string") : def.gateway.accounts,
|
|
7161
|
+
workspaces: Array.isArray(gateway.workspaces) ? gateway.workspaces.filter((w) => typeof w === "string" && w !== "") : def.gateway.workspaces,
|
|
7162
|
+
cookieSecure: typeof gateway.cookieSecure === "boolean" ? gateway.cookieSecure : def.gateway.cookieSecure,
|
|
7163
|
+
allowWorkspaceCreate: typeof gateway.allowWorkspaceCreate === "boolean" ? gateway.allowWorkspaceCreate : def.gateway.allowWorkspaceCreate,
|
|
7164
|
+
totpEnabled: typeof gateway.totpEnabled === "boolean" ? gateway.totpEnabled : def.gateway.totpEnabled
|
|
7018
7165
|
},
|
|
7019
7166
|
rebuild: {
|
|
7020
7167
|
enabled: typeof rebuild.enabled === "boolean" ? rebuild.enabled : def.rebuild.enabled,
|
|
@@ -7023,22 +7170,20 @@ function mergeWithDefaults(raw) {
|
|
|
7023
7170
|
naming: { enabled: typeof naming.enabled === "boolean" ? naming.enabled : def.naming.enabled }
|
|
7024
7171
|
};
|
|
7025
7172
|
}
|
|
7026
|
-
/**
|
|
7027
|
-
function readAdvancedSettings(
|
|
7028
|
-
return mergeWithDefaults(
|
|
7173
|
+
/** 读取全局配置(文件缺失/坏 JSON → 默认值) */
|
|
7174
|
+
function readAdvancedSettings() {
|
|
7175
|
+
return mergeWithDefaults(readFileSafe(globalConfigPath()));
|
|
7029
7176
|
}
|
|
7030
|
-
/**
|
|
7031
|
-
function writeAdvancedSettings(
|
|
7032
|
-
|
|
7033
|
-
all[ADVANCED_SECTION] = settings;
|
|
7034
|
-
writeAll(root, all);
|
|
7177
|
+
/** 写入全局配置(整体替换) */
|
|
7178
|
+
function writeAdvancedSettings(settings) {
|
|
7179
|
+
writeFileSafe(globalConfigPath(), settings);
|
|
7035
7180
|
}
|
|
7036
7181
|
/**
|
|
7037
|
-
*
|
|
7182
|
+
* 部分更新:传入 Partial,深合并到现有值。
|
|
7038
7183
|
* accounts 传入数组 → 整体替换;accounts 未传 → 保留现有。
|
|
7039
7184
|
*/
|
|
7040
|
-
function updateAdvancedSettings(
|
|
7041
|
-
const current = readAdvancedSettings(
|
|
7185
|
+
function updateAdvancedSettings(patch) {
|
|
7186
|
+
const current = readAdvancedSettings();
|
|
7042
7187
|
const gw = patch.gateway;
|
|
7043
7188
|
const rb = patch.rebuild;
|
|
7044
7189
|
const nm = patch.naming;
|
|
@@ -7047,7 +7192,11 @@ function updateAdvancedSettings(root, patch) {
|
|
|
7047
7192
|
enabled: typeof gw.enabled === "boolean" ? gw.enabled : current.gateway.enabled,
|
|
7048
7193
|
host: typeof gw.host === "string" && gw.host !== "" ? gw.host : current.gateway.host,
|
|
7049
7194
|
port: typeof gw.port === "number" && Number.isInteger(gw.port) ? gw.port : current.gateway.port,
|
|
7050
|
-
accounts: Array.isArray(gw.accounts) ? gw.accounts : current.gateway.accounts
|
|
7195
|
+
accounts: Array.isArray(gw.accounts) ? gw.accounts : current.gateway.accounts,
|
|
7196
|
+
workspaces: Array.isArray(gw.workspaces) ? gw.workspaces.filter((w) => typeof w === "string" && w !== "") : current.gateway.workspaces,
|
|
7197
|
+
cookieSecure: typeof gw.cookieSecure === "boolean" ? gw.cookieSecure : current.gateway.cookieSecure,
|
|
7198
|
+
allowWorkspaceCreate: typeof gw.allowWorkspaceCreate === "boolean" ? gw.allowWorkspaceCreate : current.gateway.allowWorkspaceCreate,
|
|
7199
|
+
totpEnabled: typeof gw.totpEnabled === "boolean" ? gw.totpEnabled : current.gateway.totpEnabled
|
|
7051
7200
|
} : current.gateway,
|
|
7052
7201
|
rebuild: rb !== void 0 ? {
|
|
7053
7202
|
enabled: typeof rb.enabled === "boolean" ? rb.enabled : current.rebuild.enabled,
|
|
@@ -7055,10 +7204,25 @@ function updateAdvancedSettings(root, patch) {
|
|
|
7055
7204
|
} : current.rebuild,
|
|
7056
7205
|
naming: nm !== void 0 ? { enabled: typeof nm.enabled === "boolean" ? nm.enabled : current.naming.enabled } : current.naming
|
|
7057
7206
|
};
|
|
7058
|
-
writeAdvancedSettings(
|
|
7207
|
+
writeAdvancedSettings(next);
|
|
7059
7208
|
return next;
|
|
7060
7209
|
}
|
|
7061
|
-
/**
|
|
7210
|
+
/**
|
|
7211
|
+
* 一次性迁移(v1.21.x → v1.22):旧版把 `serenityAdvanced` 存在 CCC localstore.json;
|
|
7212
|
+
* 新版归 plugin 全局文件。全局文件已存在 → 跳过(幂等);localstore 无旧节 → 跳过。
|
|
7213
|
+
* @param root - CCC 根(localstore.json 所在目录)
|
|
7214
|
+
* @returns true = 已迁移(旧节保留在 localstore 供回滚,读取方以全局文件为准)
|
|
7215
|
+
*/
|
|
7216
|
+
function migrateLegacyLocalstore(root) {
|
|
7217
|
+
if (!root) return false;
|
|
7218
|
+
const gpath = globalConfigPath();
|
|
7219
|
+
if (existsSync(gpath)) return false;
|
|
7220
|
+
const legacy = readFileSafe(join(root, "localstore.json"))[ADVANCED_SECTION];
|
|
7221
|
+
if (legacy === void 0 || legacy === null || typeof legacy !== "object") return false;
|
|
7222
|
+
writeFileSafe(gpath, legacy);
|
|
7223
|
+
return true;
|
|
7224
|
+
}
|
|
7225
|
+
/** 持久化 → wire(剥离 passHash/totpSecret;只留布尔) */
|
|
7062
7226
|
function toWire(settings) {
|
|
7063
7227
|
return {
|
|
7064
7228
|
gateway: {
|
|
@@ -7068,8 +7232,13 @@ function toWire(settings) {
|
|
|
7068
7232
|
accounts: settings.gateway.accounts.map((a) => ({
|
|
7069
7233
|
id: a.id,
|
|
7070
7234
|
user: a.user,
|
|
7071
|
-
hasPassword: a.passHash !== ""
|
|
7072
|
-
|
|
7235
|
+
hasPassword: a.passHash !== "",
|
|
7236
|
+
hasTotp: typeof a.totpSecret === "string" && a.totpSecret !== ""
|
|
7237
|
+
})),
|
|
7238
|
+
workspaces: [...settings.gateway.workspaces],
|
|
7239
|
+
cookieSecure: settings.gateway.cookieSecure,
|
|
7240
|
+
allowWorkspaceCreate: settings.gateway.allowWorkspaceCreate,
|
|
7241
|
+
totpEnabled: settings.gateway.totpEnabled
|
|
7073
7242
|
},
|
|
7074
7243
|
rebuild: settings.rebuild,
|
|
7075
7244
|
naming: settings.naming
|
|
@@ -7080,30 +7249,44 @@ function toWire(settings) {
|
|
|
7080
7249
|
* accounts 元素可选带 `pass`:非空 → 重新 hash;空/缺省 → 保留现有 hash(按 id 匹配)。
|
|
7081
7250
|
* 新账号(id 不在现有)必须带非空 pass,否则抛错(无法生成 hash)。
|
|
7082
7251
|
*/
|
|
7083
|
-
function applyWirePatch(
|
|
7084
|
-
const current = readAdvancedSettings(
|
|
7252
|
+
function applyWirePatch(wire) {
|
|
7253
|
+
const current = readAdvancedSettings();
|
|
7085
7254
|
const patch = {};
|
|
7086
7255
|
if (wire.gateway !== void 0) {
|
|
7087
7256
|
const gwPatch = { ...current.gateway };
|
|
7088
7257
|
if (typeof wire.gateway.enabled === "boolean") gwPatch.enabled = wire.gateway.enabled;
|
|
7089
7258
|
if (typeof wire.gateway.host === "string" && wire.gateway.host !== "") gwPatch.host = wire.gateway.host;
|
|
7090
7259
|
if (typeof wire.gateway.port === "number" && Number.isInteger(wire.gateway.port)) gwPatch.port = wire.gateway.port;
|
|
7260
|
+
if (Array.isArray(wire.gateway.workspaces)) gwPatch.workspaces = wire.gateway.workspaces.filter((w) => typeof w === "string" && w !== "");
|
|
7261
|
+
if (typeof wire.gateway.cookieSecure === "boolean") gwPatch.cookieSecure = wire.gateway.cookieSecure;
|
|
7262
|
+
if (typeof wire.gateway.allowWorkspaceCreate === "boolean") gwPatch.allowWorkspaceCreate = wire.gateway.allowWorkspaceCreate;
|
|
7263
|
+
if (typeof wire.gateway.totpEnabled === "boolean") gwPatch.totpEnabled = wire.gateway.totpEnabled;
|
|
7091
7264
|
if (Array.isArray(wire.gateway.accounts)) {
|
|
7092
7265
|
const byId = new Map(current.gateway.accounts.map((a) => [a.id, a]));
|
|
7093
7266
|
gwPatch.accounts = wire.gateway.accounts.map((a) => {
|
|
7094
7267
|
const existing = byId.get(a.id);
|
|
7095
7268
|
const pass = a.pass;
|
|
7096
|
-
|
|
7269
|
+
const totp = a.totpSecret;
|
|
7270
|
+
const totpReset = a.totpReset === true;
|
|
7271
|
+
const totpConfirm = a.totpConfirm;
|
|
7272
|
+
if (typeof totp === "string" && totp !== "") {
|
|
7273
|
+
if (typeof totpConfirm !== "string" || verifyTotpCode(totp, totpConfirm) === null) throw new Error(`Account "${a.user}" TOTP confirmation code invalid — enter the 6-digit code currently shown by the authenticator`);
|
|
7274
|
+
}
|
|
7275
|
+
const nextTotp = totpReset ? void 0 : typeof totp === "string" && totp !== "" ? totp : existing?.totpSecret;
|
|
7276
|
+
const withTotp = {
|
|
7097
7277
|
id: a.id,
|
|
7098
7278
|
user: a.user,
|
|
7279
|
+
...nextTotp === void 0 ? {} : { totpSecret: nextTotp }
|
|
7280
|
+
};
|
|
7281
|
+
if (typeof pass === "string" && pass !== "") return {
|
|
7282
|
+
...withTotp,
|
|
7099
7283
|
passHash: hashPassword(pass)
|
|
7100
7284
|
};
|
|
7101
7285
|
if (existing) return {
|
|
7102
|
-
|
|
7103
|
-
user: a.user,
|
|
7286
|
+
...withTotp,
|
|
7104
7287
|
passHash: existing.passHash
|
|
7105
7288
|
};
|
|
7106
|
-
throw new Error(
|
|
7289
|
+
throw new Error(`Account "${a.user}" (id=${a.id}) has no password and no existing hash — new accounts must set a password`);
|
|
7107
7290
|
});
|
|
7108
7291
|
}
|
|
7109
7292
|
patch.gateway = gwPatch;
|
|
@@ -7115,7 +7298,7 @@ function applyWirePatch(root, wire) {
|
|
|
7115
7298
|
patch.rebuild = rbPatch;
|
|
7116
7299
|
}
|
|
7117
7300
|
if (wire.naming !== void 0 && typeof wire.naming.enabled === "boolean") patch.naming = { enabled: wire.naming.enabled };
|
|
7118
|
-
return updateAdvancedSettings(
|
|
7301
|
+
return updateAdvancedSettings(patch);
|
|
7119
7302
|
}
|
|
7120
7303
|
//#endregion
|
|
7121
7304
|
//#region src/api.ts
|
|
@@ -7155,7 +7338,7 @@ function saveImageToTmp(root, mediaType, data) {
|
|
|
7155
7338
|
writeFileSync(join(dir, filename), bytes);
|
|
7156
7339
|
return `${IMAGE_UPLOAD_DIR}/${filename}`;
|
|
7157
7340
|
}
|
|
7158
|
-
function readBody(req, maxBytes) {
|
|
7341
|
+
function readBody$1(req, maxBytes) {
|
|
7159
7342
|
return new Promise((resolve, reject) => {
|
|
7160
7343
|
let data = "";
|
|
7161
7344
|
req.on("data", (chunk) => {
|
|
@@ -7177,14 +7360,27 @@ function sendJson(res, code, body) {
|
|
|
7177
7360
|
});
|
|
7178
7361
|
res.end(payload);
|
|
7179
7362
|
}
|
|
7180
|
-
/**
|
|
7363
|
+
/**
|
|
7364
|
+
* 从请求参数解析 workspace:优先 sessionId → 会话 header.cwd;其次 workspace 参数;
|
|
7365
|
+
* 无参 → 遍历 live sessions 找第一个 CCC 会话的 cwd(远程访问时 sessionId 可能缺失,
|
|
7366
|
+
* 回退 process.cwd()=$HOME 会错误显示"未激活"——v1.22.2 修复);最后进程 cwd。
|
|
7367
|
+
* 纯逻辑(可单测):resolveWorkspaceCore(sessionCwd, workspaceParam, listCwds)。
|
|
7368
|
+
*/
|
|
7369
|
+
function resolveWorkspaceCore(sessionCwd, workspaceParam, listCwds, fallback) {
|
|
7370
|
+
if (sessionCwd) return sessionCwd;
|
|
7371
|
+
if (workspaceParam) return workspaceParam;
|
|
7372
|
+
for (const cwd of listCwds()) if (cwd && findSerenityRoot(cwd)) return cwd;
|
|
7373
|
+
return fallback;
|
|
7374
|
+
}
|
|
7181
7375
|
function resolveWorkspace(ctx, params) {
|
|
7182
|
-
|
|
7183
|
-
|
|
7184
|
-
|
|
7185
|
-
|
|
7186
|
-
|
|
7187
|
-
|
|
7376
|
+
let sessionCwd;
|
|
7377
|
+
if (params.sessionId) sessionCwd = (ctx.sessions?.get?.(params.sessionId))?.header?.cwd;
|
|
7378
|
+
let listCwds = () => [];
|
|
7379
|
+
try {
|
|
7380
|
+
const sessions = ctx.sessions;
|
|
7381
|
+
if (typeof sessions?.list === "function") listCwds = () => sessions.list().map((s) => s.header?.cwd ?? "").filter(Boolean);
|
|
7382
|
+
} catch {}
|
|
7383
|
+
return resolveWorkspaceCore(sessionCwd, params.workspace, listCwds, process.cwd());
|
|
7188
7384
|
}
|
|
7189
7385
|
function registerStatusApi(ctx, opts = {}) {
|
|
7190
7386
|
const configPaths = opts.configPaths ?? DEFAULT_SERENITY_CONFIG_PATHS;
|
|
@@ -7225,7 +7421,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
7225
7421
|
sendJson(res, 403, { error: "图片落盘仅限 WebUI(client 专用)" });
|
|
7226
7422
|
return;
|
|
7227
7423
|
}
|
|
7228
|
-
const raw = await readBody(req, 20971520);
|
|
7424
|
+
const raw = await readBody$1(req, 20971520);
|
|
7229
7425
|
const body = JSON.parse(raw);
|
|
7230
7426
|
const workspace = resolveWorkspace(ctx, {
|
|
7231
7427
|
sessionId: body.sessionId,
|
|
@@ -7268,7 +7464,7 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
7268
7464
|
sendJson(res, 403, { error: "safe-mode 切换仅限 WebUI(agent 不可自行开关)" });
|
|
7269
7465
|
return;
|
|
7270
7466
|
}
|
|
7271
|
-
const raw = await readBody(req, 65536);
|
|
7467
|
+
const raw = await readBody$1(req, 65536);
|
|
7272
7468
|
const body = JSON.parse(raw);
|
|
7273
7469
|
const workspace = resolveWorkspace(ctx, {
|
|
7274
7470
|
sessionId: body.sessionId,
|
|
@@ -7302,35 +7498,16 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
7302
7498
|
return;
|
|
7303
7499
|
}
|
|
7304
7500
|
if (req.method === "GET") {
|
|
7305
|
-
|
|
7306
|
-
const workspace = resolveWorkspace(ctx, {
|
|
7307
|
-
sessionId: url.searchParams.get("sessionId") ?? void 0,
|
|
7308
|
-
workspace: url.searchParams.get("workspace") ?? void 0
|
|
7309
|
-
});
|
|
7310
|
-
const root = findSerenityRoot(workspace);
|
|
7311
|
-
if (!root) {
|
|
7312
|
-
sendJson(res, 200, {
|
|
7313
|
-
config: null,
|
|
7314
|
-
error: `no CCC found from workspace: ${workspace}`
|
|
7315
|
-
});
|
|
7316
|
-
return;
|
|
7317
|
-
}
|
|
7318
|
-
sendJson(res, 200, { config: toWire(readAdvancedSettings(root)) });
|
|
7501
|
+
sendJson(res, 200, { config: toWire(readAdvancedSettings()) });
|
|
7319
7502
|
return;
|
|
7320
7503
|
}
|
|
7321
7504
|
if (req.method === "PUT") {
|
|
7322
|
-
const raw = await readBody(req, 131072);
|
|
7323
|
-
const
|
|
7324
|
-
|
|
7325
|
-
|
|
7326
|
-
|
|
7327
|
-
});
|
|
7328
|
-
const root = findSerenityRoot(workspace);
|
|
7329
|
-
if (!root) {
|
|
7330
|
-
sendJson(res, 404, { error: `no CCC found from workspace: ${workspace}` });
|
|
7331
|
-
return;
|
|
7332
|
-
}
|
|
7333
|
-
sendJson(res, 200, { config: toWire(applyWirePatch(root, body.config ?? {})) });
|
|
7505
|
+
const raw = await readBody$1(req, 131072);
|
|
7506
|
+
const saved = applyWirePatch(JSON.parse(raw).config ?? {});
|
|
7507
|
+
try {
|
|
7508
|
+
ctx.emit?.("serenity/config-updated");
|
|
7509
|
+
} catch {}
|
|
7510
|
+
sendJson(res, 200, { config: toWire(saved) });
|
|
7334
7511
|
return;
|
|
7335
7512
|
}
|
|
7336
7513
|
sendJson(res, 405, { error: "method not allowed" });
|
|
@@ -7356,9 +7533,9 @@ function registerEnv(ctx) {
|
|
|
7356
7533
|
ctx.shellEnv.register({
|
|
7357
7534
|
name: "dsh-serenity-hooks",
|
|
7358
7535
|
variables: {
|
|
7359
|
-
DSH_SERENITY_ROOT: { description: "CCC
|
|
7360
|
-
DSH_SERENITY_CCC: { description: "CCC
|
|
7361
|
-
DSH_SERENITY_VERSION: { description: "ACC
|
|
7536
|
+
DSH_SERENITY_ROOT: { description: "CCC root directory (Serenity cognitive container)" },
|
|
7537
|
+
DSH_SERENITY_CCC: { description: "CCC name (root directory basename)" },
|
|
7538
|
+
DSH_SERENITY_VERSION: { description: "ACC plugin version" }
|
|
7362
7539
|
},
|
|
7363
7540
|
resolve(execution) {
|
|
7364
7541
|
return resolveSerenityEnv((execution.agent?.session)?.header?.cwd ?? process.cwd());
|
|
@@ -7478,41 +7655,126 @@ function registerOpencodeSkills(ctx) {
|
|
|
7478
7655
|
ctx.skills.registerProvider(() => provider);
|
|
7479
7656
|
}
|
|
7480
7657
|
//#endregion
|
|
7481
|
-
//#region src/gateway.ts
|
|
7482
|
-
/**
|
|
7483
|
-
|
|
7484
|
-
|
|
7485
|
-
|
|
7486
|
-
|
|
7487
|
-
|
|
7488
|
-
|
|
7489
|
-
|
|
7490
|
-
|
|
7491
|
-
|
|
7492
|
-
<form method="post" action="/serenity/login">
|
|
7493
|
-
<input type="text" name="user" placeholder="用户名" autocomplete="username" required autofocus>
|
|
7494
|
-
<input type="password" name="password" placeholder="密码" autocomplete="current-password" required>
|
|
7495
|
-
<button type="submit">登录</button>
|
|
7496
|
-
<div class="error">${extra}</div>
|
|
7497
|
-
</form></div></body></html>`;
|
|
7498
|
-
}
|
|
7499
|
-
/** 账号验证(纯逻辑):user + password 对 localstore accounts 匹配(scrypt) */
|
|
7658
|
+
//#region src/gateway-auth.ts
|
|
7659
|
+
/**
|
|
7660
|
+
* gateway-auth.ts — F1 双端口网关:认证域纯逻辑(v1.22.4 安全加固)
|
|
7661
|
+
*
|
|
7662
|
+
* 从 gateway.ts 拆分(S142 熵点治理,v1.22.8):认证纯逻辑独立成模块——
|
|
7663
|
+
* 登录验证(scrypt)/ 会话 token(滑动 TTL + 吊销)/ 失败锁定(账号维度指数退避)/
|
|
7664
|
+
* CSRF(双提交 + Origin 校验)/ cookie 解析 / 登录页 HTML。
|
|
7665
|
+
* gateway.ts 保留 HTTP 装配(startGateway/registerGateway)并从本模块 import。
|
|
7666
|
+
*
|
|
7667
|
+
* 纯函数设计(可单测):无 socket/无 HTTP 依赖,全部导出可注入测试。
|
|
7668
|
+
*/
|
|
7500
7669
|
function verifyGatewayLogin(accounts, user, password) {
|
|
7501
7670
|
const account = accounts.find((a) => a.user === user);
|
|
7502
7671
|
if (!account) return false;
|
|
7503
7672
|
return verifyPassword(password, account.passHash);
|
|
7504
7673
|
}
|
|
7505
|
-
/**
|
|
7506
|
-
const
|
|
7507
|
-
/**
|
|
7508
|
-
function issueToken() {
|
|
7674
|
+
/** 内存会话表(重启即清空——用户决策) */
|
|
7675
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
7676
|
+
/** 颁发会话 token(Hex 32 字节) */
|
|
7677
|
+
function issueToken(user) {
|
|
7509
7678
|
const token = randomBytes(32).toString("hex");
|
|
7510
|
-
|
|
7679
|
+
sessions.set(token, {
|
|
7680
|
+
token,
|
|
7681
|
+
lastActiveAt: Date.now(),
|
|
7682
|
+
user
|
|
7683
|
+
});
|
|
7511
7684
|
return token;
|
|
7512
7685
|
}
|
|
7513
|
-
/**
|
|
7686
|
+
/** 主动吊销会话(登出);返回是否确实存在 */
|
|
7687
|
+
function revokeToken(token) {
|
|
7688
|
+
return sessions.delete(token);
|
|
7689
|
+
}
|
|
7690
|
+
/**
|
|
7691
|
+
* 校验 token 有效性(滑动过期:有效则刷新 lastActiveAt)。
|
|
7692
|
+
* @returns 有效会话或 undefined
|
|
7693
|
+
*/
|
|
7514
7694
|
function validateToken(token) {
|
|
7515
|
-
|
|
7695
|
+
if (typeof token !== "string" || token === "") return void 0;
|
|
7696
|
+
const session = sessions.get(token);
|
|
7697
|
+
if (session === void 0) return void 0;
|
|
7698
|
+
if (Date.now() - session.lastActiveAt > 864e5) {
|
|
7699
|
+
sessions.delete(token);
|
|
7700
|
+
return;
|
|
7701
|
+
}
|
|
7702
|
+
session.lastActiveAt = Date.now();
|
|
7703
|
+
return session;
|
|
7704
|
+
}
|
|
7705
|
+
/** 首次锁定时长(毫秒) */
|
|
7706
|
+
const FAIL_LOCK_BASE_MS = 9e5;
|
|
7707
|
+
/** 锁定时长指数退避上限 */
|
|
7708
|
+
const FAIL_LOCK_MAX_MS = 144e5;
|
|
7709
|
+
/** 账号 → 失败状态(内存;重启清零——攻击者重启服务可绕过,但公网常驻服务下有效) */
|
|
7710
|
+
const failStates = /* @__PURE__ */ new Map();
|
|
7711
|
+
/** 获取账号失败状态(纯逻辑,供测试注入) */
|
|
7712
|
+
function getFailState(user) {
|
|
7713
|
+
let st = failStates.get(user);
|
|
7714
|
+
if (!st) {
|
|
7715
|
+
st = {
|
|
7716
|
+
count: 0,
|
|
7717
|
+
lockedUntil: 0,
|
|
7718
|
+
lockRound: 0
|
|
7719
|
+
};
|
|
7720
|
+
failStates.set(user, st);
|
|
7721
|
+
}
|
|
7722
|
+
return st;
|
|
7723
|
+
}
|
|
7724
|
+
/** 重置失败状态(登录成功/管理员解封) */
|
|
7725
|
+
function resetFailState(user) {
|
|
7726
|
+
failStates.delete(user);
|
|
7727
|
+
}
|
|
7728
|
+
/** 记录一次失败;达到阈值 → 锁定(指数退避)。返回锁定剩余毫秒(0 = 未锁定) */
|
|
7729
|
+
function recordLoginFailure(user) {
|
|
7730
|
+
const st = getFailState(user);
|
|
7731
|
+
if (st.lockedUntil > 0 && Date.now() < st.lockedUntil) return st.lockedUntil - Date.now();
|
|
7732
|
+
st.count += 1;
|
|
7733
|
+
if (st.count >= 5) {
|
|
7734
|
+
const base = FAIL_LOCK_BASE_MS * 2 ** st.lockRound;
|
|
7735
|
+
st.lockedUntil = Date.now() + Math.min(base, FAIL_LOCK_MAX_MS);
|
|
7736
|
+
st.lockRound += 1;
|
|
7737
|
+
st.count = 0;
|
|
7738
|
+
return st.lockedUntil - Date.now();
|
|
7739
|
+
}
|
|
7740
|
+
return 0;
|
|
7741
|
+
}
|
|
7742
|
+
/** 剩余锁定毫秒(0 = 未锁定) */
|
|
7743
|
+
function accountLockRemaining(user) {
|
|
7744
|
+
const st = getFailState(user);
|
|
7745
|
+
return st.lockedUntil > 0 && Date.now() < st.lockedUntil ? st.lockedUntil - Date.now() : 0;
|
|
7746
|
+
}
|
|
7747
|
+
/** 生成 CSRF cookie 值(随机 32B hex) */
|
|
7748
|
+
function newCsrfToken() {
|
|
7749
|
+
return randomBytes(32).toString("hex");
|
|
7750
|
+
}
|
|
7751
|
+
/** 从请求头取 CSRF token(X-CSRF-Token 或表单字段) */
|
|
7752
|
+
function csrfFromRequest(req, body) {
|
|
7753
|
+
const header = req.headers["x-csrf-token"];
|
|
7754
|
+
if (typeof header === "string" && header !== "") return header;
|
|
7755
|
+
const form = body.get("csrf");
|
|
7756
|
+
return form && form !== "" ? form : null;
|
|
7757
|
+
}
|
|
7758
|
+
/** 常量时间比较(token 校验用) */
|
|
7759
|
+
function safeEqual(a, b) {
|
|
7760
|
+
const ab = Buffer.from(a);
|
|
7761
|
+
const bb = Buffer.from(b);
|
|
7762
|
+
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
|
7763
|
+
}
|
|
7764
|
+
/**
|
|
7765
|
+
* Origin 校验(S3):跨站 POST 被浏览器强制带 Origin(同源 GET/导航除外)。
|
|
7766
|
+
* 允许:① 同源(Origin.host === 网关自身 host)② 主端口 loopback(127.0.0.1:mainPort)。
|
|
7767
|
+
* 无 Origin(curl/非浏览器直连)→ 仅当携带有效 CSRF 双提交时放行(脚本无 cookie 无法伪造)。
|
|
7768
|
+
* @returns 是否通过
|
|
7769
|
+
*/
|
|
7770
|
+
function originAllowed(originHeader, reqHost, mainPort) {
|
|
7771
|
+
if (originHeader === void 0 || originHeader === "null") return true;
|
|
7772
|
+
try {
|
|
7773
|
+
const originHost = new URL(originHeader).host;
|
|
7774
|
+
return originHost === reqHost || originHost === `127.0.0.1:${mainPort}` || originHost === `localhost:${mainPort}`;
|
|
7775
|
+
} catch {
|
|
7776
|
+
return false;
|
|
7777
|
+
}
|
|
7516
7778
|
}
|
|
7517
7779
|
/** 从 Cookie 头解析指定 cookie 值(纯逻辑,可单测) */
|
|
7518
7780
|
function cookieValue(header, name) {
|
|
@@ -7524,43 +7786,391 @@ function cookieValue(header, name) {
|
|
|
7524
7786
|
}
|
|
7525
7787
|
}
|
|
7526
7788
|
/**
|
|
7527
|
-
*
|
|
7789
|
+
* 登录页:内嵌 HTML 无外部资源——适配任何部署。
|
|
7790
|
+
* 移动端关键点:
|
|
7791
|
+
* - viewport meta(防止移动浏览器按 980px 视口缩放)
|
|
7792
|
+
* - 触控目标 ≥ 44px(Apple HIG);输入字号 ≥ 16px(iOS 聚焦不自动放大)
|
|
7793
|
+
* - env(safe-area-inset-*) 适配刘海屏/底部手势条
|
|
7794
|
+
* - color-scheme: dark + 明暗自适应
|
|
7795
|
+
* @param extra - 错误提示文案
|
|
7796
|
+
* @param csrf - CSRF token(隐藏字段随表单双提交;无 = 旧调用方兼容)
|
|
7797
|
+
*/
|
|
7798
|
+
function loginPageHtml(extra, csrf) {
|
|
7799
|
+
return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
|
7800
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
7801
|
+
<meta name="color-scheme" content="dark light">
|
|
7802
|
+
<meta name="theme-color" content="#111114">
|
|
7803
|
+
<meta name="referrer" content="no-referrer">
|
|
7804
|
+
<title>Serenity 登录</title>
|
|
7805
|
+
<style>
|
|
7806
|
+
:root{color-scheme:dark}
|
|
7807
|
+
*{box-sizing:border-box}
|
|
7808
|
+
html,body{height:100%}
|
|
7809
|
+
body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;display:flex;align-items:center;justify-content:center;margin:0;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);background:#111114;color:#eee}
|
|
7810
|
+
.card{background:#1c1c20;padding:32px 28px;border-radius:16px;width:min(340px,calc(100vw - 48px));box-shadow:0 12px 40px rgba(0,0,0,.55);border:1px solid rgba(255,255,255,.06)}
|
|
7811
|
+
h1{font-size:20px;margin:0 0 4px;text-align:center;letter-spacing:.02em}
|
|
7812
|
+
.sub{font-size:13px;color:#999;text-align:center;margin:0 0 24px}
|
|
7813
|
+
label{display:block;font-size:13px;color:#bbb;margin:14px 0 6px}
|
|
7814
|
+
input{width:100%;padding:14px 14px;margin:0;border:1px solid #3a3a40;border-radius:10px;background:#141418;color:#eee;font-size:16px;-webkit-appearance:none;appearance:none}
|
|
7815
|
+
input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.25)}
|
|
7816
|
+
input::placeholder{color:#666}
|
|
7817
|
+
#f-code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.35em;text-align:center;font-weight:600}
|
|
7818
|
+
button{width:100%;padding:15px 14px;margin-top:24px;border:0;border-radius:10px;background:#3b82f6;color:#fff;font-size:16px;font-weight:600;cursor:pointer;min-height:50px;-webkit-appearance:none;appearance:none}
|
|
7819
|
+
button:active{background:#2563eb}
|
|
7820
|
+
.error{color:#f87171;font-size:13px;min-height:18px;margin-top:12px;text-align:center}
|
|
7821
|
+
.hint{color:#777;font-size:12px;margin-top:10px;text-align:center}
|
|
7822
|
+
.foot{margin-top:20px;text-align:center;font-size:12px;color:#666}
|
|
7823
|
+
@media (prefers-color-scheme:light){
|
|
7824
|
+
body{background:#f4f4f6;color:#1a1a1e}
|
|
7825
|
+
.card{background:#fff;border-color:rgba(0,0,0,.08);box-shadow:0 12px 40px rgba(0,0,0,.12)}
|
|
7826
|
+
.sub{color:#666}
|
|
7827
|
+
label{color:#444}
|
|
7828
|
+
input{background:#fafafa;border-color:#d0d0d6;color:#1a1a1e}
|
|
7829
|
+
input::placeholder{color:#aaa}
|
|
7830
|
+
.hint{color:#999}
|
|
7831
|
+
.foot{color:#999}
|
|
7832
|
+
}
|
|
7833
|
+
</style></head><body><div class="card">
|
|
7834
|
+
<h1>🔐 Serenity Web UI</h1>
|
|
7835
|
+
<p class="sub">宁静号 · 外部访问</p>
|
|
7836
|
+
<form method="post" action="/serenity/login">
|
|
7837
|
+
${csrf ? `<input type="hidden" name="csrf" value="${csrf}">` : ""}
|
|
7838
|
+
<label for="f-user">用户名</label>
|
|
7839
|
+
<input id="f-user" type="text" name="user" placeholder="用户名" autocomplete="username" autocapitalize="none" autocorrect="off" required autofocus enterkeyhint="next">
|
|
7840
|
+
<label for="f-pass">密码</label>
|
|
7841
|
+
<input id="f-pass" type="password" name="password" placeholder="密码" autocomplete="current-password" required enterkeyhint="next">
|
|
7842
|
+
<label for="f-code">验证码(已绑定 Authenticator 时填写)</label>
|
|
7843
|
+
<input id="f-code" type="text" name="code" placeholder="6 位验证码" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" enterkeyhint="go">
|
|
7844
|
+
<button type="submit">登录</button>
|
|
7845
|
+
<div class="error">${extra}</div>
|
|
7846
|
+
<p class="hint">未绑定验证器的账号只需用户名 + 密码</p>
|
|
7847
|
+
</form>
|
|
7848
|
+
<p class="foot">Serenity ACC · dsh-serenity-hooks</p>
|
|
7849
|
+
</div></body></html>`;
|
|
7850
|
+
}
|
|
7851
|
+
//#endregion
|
|
7852
|
+
//#region src/gateway-proxy.ts
|
|
7853
|
+
/**
|
|
7854
|
+
* gateway-proxy.ts — F1 双端口网关:代理辅助纯逻辑(v1.22.1-1.22.3 稳定性修复)
|
|
7855
|
+
*
|
|
7856
|
+
* 从 gateway.ts 拆分(S142 熵点治理,v1.22.8):
|
|
7857
|
+
* - crypto.randomUUID polyfill(非安全上下文修复,v1.22.0)
|
|
7858
|
+
* - 反代请求头构造(信任栅栏修复:Host + Origin 同步改写 loopback,v1.22.1)
|
|
7859
|
+
* - workspace 白名单过滤(响应 items 前缀过滤 + create 校验 + 403 RPC 响应,v1.22)
|
|
7860
|
+
* - HTML polyfill 注入(幂等 marker)
|
|
7861
|
+
* 纯函数设计(可单测);gateway.ts 装配层 import 使用。
|
|
7862
|
+
*/
|
|
7863
|
+
/**
|
|
7864
|
+
* 经第二端口 http://LAN-IP:3081 访问 = 非安全上下文 → DSH client 的
|
|
7865
|
+
* `crypto.randomUUID()`(ui-conversation/service.ts 等)抛错,provider 目录加载失败。
|
|
7866
|
+
* 用 `crypto.getRandomValues` 实现(与 DSH 官方 random-uuid.ts 同算法),
|
|
7867
|
+
* 零改 DSH——gateway 反代 HTML 时注入。
|
|
7868
|
+
*/
|
|
7869
|
+
const RANDOM_UUID_POLYFILL = `<script>
|
|
7870
|
+
(function () {
|
|
7871
|
+
if (typeof crypto === 'undefined' || typeof crypto.randomUUID === 'function') return
|
|
7872
|
+
try {
|
|
7873
|
+
crypto.randomUUID = function () {
|
|
7874
|
+
var bytes = crypto.getRandomValues(new Uint8Array(16))
|
|
7875
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
|
7876
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
|
7877
|
+
var hex = Array.from(bytes, function (b) { return b.toString(16).padStart(2, '0') }).join('')
|
|
7878
|
+
return hex.slice(0, 8) + '-' + hex.slice(8, 12) + '-' + hex.slice(12, 16) + '-' + hex.slice(16, 20) + '-' + hex.slice(20)
|
|
7879
|
+
}
|
|
7880
|
+
} catch (e) { /* getRandomValues 也不可用则放弃 */ }
|
|
7881
|
+
})()
|
|
7882
|
+
<\/script>`;
|
|
7883
|
+
/** 注入标记(幂等:已注入的 HTML 不重复注入) */
|
|
7884
|
+
const POLYFILL_MARKER = "data-sp-randomuuid-polyfill";
|
|
7885
|
+
/**
|
|
7886
|
+
* workspace.list 响应过滤(v1.22 白名单):
|
|
7887
|
+
* DSH client→server RPC 全部走 HTTP JSON(`POST /api/workspace.list`,WS 仅下行推送)。
|
|
7888
|
+
* 白名单(workspaces 路径前缀)非空 → 只保留匹配前缀的 items;
|
|
7889
|
+
* 空 = 全部允许(默认,向后兼容)。
|
|
7890
|
+
*/
|
|
7891
|
+
function filterWorkspaceList(body, allowPrefixes) {
|
|
7892
|
+
if (allowPrefixes.length === 0) return body;
|
|
7893
|
+
try {
|
|
7894
|
+
const parsed = JSON.parse(body);
|
|
7895
|
+
const value = parsed?.result?.value;
|
|
7896
|
+
if (parsed?.result?.ok !== true || !value || !Array.isArray(value.items)) return body;
|
|
7897
|
+
const keep = (path) => typeof path === "string" && allowPrefixes.some((p) => path.startsWith(p));
|
|
7898
|
+
value.items = value.items.filter((item) => keep(item.path));
|
|
7899
|
+
return JSON.stringify(parsed);
|
|
7900
|
+
} catch {
|
|
7901
|
+
return body;
|
|
7902
|
+
}
|
|
7903
|
+
}
|
|
7904
|
+
/**
|
|
7905
|
+
* 校验 workspace.create 请求路径是否在白名单内(v1.22):
|
|
7906
|
+
* 白名单非空且路径不匹配 → 拒绝(由调用方构造 403 RPC 响应)。
|
|
7907
|
+
*/
|
|
7908
|
+
function workspaceAllowed(allowPrefixes, path) {
|
|
7909
|
+
if (allowPrefixes.length === 0) return true;
|
|
7910
|
+
return typeof path === "string" && allowPrefixes.some((p) => path.startsWith(p));
|
|
7911
|
+
}
|
|
7912
|
+
/** 构造 workspace.create 拒绝的 JSON RPC 响应体(code=forbidden) */
|
|
7913
|
+
function workspaceDenyResponse(rpcId) {
|
|
7914
|
+
return JSON.stringify({
|
|
7915
|
+
type: "server-response",
|
|
7916
|
+
rpcId,
|
|
7917
|
+
result: {
|
|
7918
|
+
ok: false,
|
|
7919
|
+
error: {
|
|
7920
|
+
code: "forbidden",
|
|
7921
|
+
message: "workspace not in external allowlist",
|
|
7922
|
+
details: {}
|
|
7923
|
+
}
|
|
7924
|
+
}
|
|
7925
|
+
});
|
|
7926
|
+
}
|
|
7927
|
+
/** 在 HTML 的 </head> 前注入 polyfill(幂等:含 marker 则跳过) */
|
|
7928
|
+
function injectPolyfillHtml(html) {
|
|
7929
|
+
if (html.includes(POLYFILL_MARKER)) return html;
|
|
7930
|
+
const head = RANDOM_UUID_POLYFILL.replace("<script>", `<script ${POLYFILL_MARKER}="1">`);
|
|
7931
|
+
if (html.includes("</head>")) return html.replace("</head>", `${head}\n</head>`);
|
|
7932
|
+
return `${head}\n${html}`;
|
|
7933
|
+
}
|
|
7934
|
+
/**
|
|
7935
|
+
* 反代请求头构造(v1.22.1 信任栅栏修复,纯逻辑可测):
|
|
7936
|
+
* DSH isTrustedApiRequest 要求 Origin.host === Host.host——Host 改写为 loopback 后
|
|
7937
|
+
* Origin 必须同步改写(浏览器 POST 必带 Origin,透传外部地址 → 403)。
|
|
7938
|
+
*/
|
|
7939
|
+
function buildProxyHeaders(reqHeaders, mainPort, bodyOverride) {
|
|
7940
|
+
const headers = {
|
|
7941
|
+
...reqHeaders,
|
|
7942
|
+
host: `127.0.0.1:${mainPort}`,
|
|
7943
|
+
origin: `http://127.0.0.1:${mainPort}`
|
|
7944
|
+
};
|
|
7945
|
+
if (bodyOverride !== void 0) headers["content-length"] = Buffer.byteLength(bodyOverride);
|
|
7946
|
+
return headers;
|
|
7947
|
+
}
|
|
7948
|
+
//#endregion
|
|
7949
|
+
//#region src/gateway.ts
|
|
7950
|
+
/** 读取请求体(≤ maxBytes;超限 reject) */
|
|
7951
|
+
function readBody(req, maxBytes) {
|
|
7952
|
+
return new Promise((resolve, reject) => {
|
|
7953
|
+
let data = "";
|
|
7954
|
+
req.on("data", (chunk) => {
|
|
7955
|
+
data += chunk.toString("utf-8");
|
|
7956
|
+
if (data.length > maxBytes) {
|
|
7957
|
+
reject(/* @__PURE__ */ new Error("body too large"));
|
|
7958
|
+
req.destroy();
|
|
7959
|
+
}
|
|
7960
|
+
});
|
|
7961
|
+
req.on("end", () => resolve(data));
|
|
7962
|
+
req.on("error", reject);
|
|
7963
|
+
});
|
|
7964
|
+
}
|
|
7965
|
+
/**
|
|
7966
|
+
* 启动第二监听器。返回 { server, dispose }——dispose 关 server(**不清 token**:
|
|
7967
|
+
* token 模块级,进程重启自然清空;热重建清 token → 已登录用户 WS 断 + 重连 cookie 无效)。
|
|
7528
7968
|
* @param config - 监听/代理配置。
|
|
7529
|
-
* @param getAccounts - 运行时读取账号列表(
|
|
7969
|
+
* @param getAccounts - 运行时读取账号列表(plugin 全局文件;gateway.enabled 开关在调用方判)。
|
|
7530
7970
|
*/
|
|
7531
7971
|
function startGateway(config, getAccounts) {
|
|
7532
7972
|
const { host, port, cookieName, mainPort, loginDelayMs } = config;
|
|
7533
|
-
|
|
7973
|
+
const allowWorkspaces = config.allowWorkspaces ?? [];
|
|
7974
|
+
const cookieSecure = config.cookieSecure === true;
|
|
7975
|
+
const allowWorkspaceCreate = config.allowWorkspaceCreate !== false;
|
|
7976
|
+
const totpEnabled = config.totpEnabled === true;
|
|
7977
|
+
/** 校验请求 Cookie 是否含有效 token(v1.22.4:滑动过期 + 返回会话供审计) */
|
|
7534
7978
|
const authed = (req) => validateToken(cookieValue(req.headers.cookie, cookieName));
|
|
7535
|
-
/**
|
|
7536
|
-
|
|
7979
|
+
/**
|
|
7980
|
+
* 反向代理:改写 Host + Origin 头 → 主端口(loopback 过信任栅栏)。
|
|
7981
|
+
* v1.22.1 修复:DSH 信任栅栏(api-request-trust.ts isTrustedApiRequest)要求
|
|
7982
|
+
* **Origin.host === Host.host**——仅改写 Host(127.0.0.1:3080)而 Origin 透传
|
|
7983
|
+
* (http://192.168.x.x:3081)→ 不一致 → 所有 /api RPC 403(host.pickDirectory 首个暴露)。
|
|
7984
|
+
* 增强:
|
|
7985
|
+
* - HTML 响应 → 注入 crypto.randomUUID polyfill(非安全上下文修复)
|
|
7986
|
+
* - /api/workspace.list 响应 → 白名单过滤 items
|
|
7987
|
+
* @param bodyOverride - workspace.create 已读 body 时的重放(白名单检查后转发)
|
|
7988
|
+
*/
|
|
7989
|
+
const proxy = (req, res, bodyOverride) => {
|
|
7990
|
+
const url = new URL(req.url ?? "/", `http://${host}:${port}`);
|
|
7991
|
+
const method = req.method === "POST" && url.pathname.startsWith("/api/") ? url.pathname.slice(5) : null;
|
|
7992
|
+
const headers = buildProxyHeaders(req.headers, mainPort, bodyOverride);
|
|
7537
7993
|
const target = request({
|
|
7538
7994
|
host: "127.0.0.1",
|
|
7539
7995
|
port: mainPort,
|
|
7540
7996
|
path: req.url ?? "/",
|
|
7541
7997
|
method: req.method,
|
|
7542
|
-
headers
|
|
7543
|
-
...req.headers,
|
|
7544
|
-
host: `127.0.0.1:${mainPort}`
|
|
7545
|
-
}
|
|
7998
|
+
headers
|
|
7546
7999
|
}, (upstream) => {
|
|
7547
|
-
|
|
8000
|
+
const status = upstream.statusCode ?? 502;
|
|
8001
|
+
const ct = String(upstream.headers["content-type"] ?? "");
|
|
8002
|
+
if (status === 200 && ct.includes("text/html")) {
|
|
8003
|
+
const chunks = [];
|
|
8004
|
+
upstream.on("data", (c) => chunks.push(c));
|
|
8005
|
+
upstream.on("end", () => {
|
|
8006
|
+
const transformed = injectPolyfillHtml(Buffer.concat(chunks).toString("utf-8"));
|
|
8007
|
+
const out = {
|
|
8008
|
+
...upstream.headers,
|
|
8009
|
+
"content-length": Buffer.byteLength(transformed)
|
|
8010
|
+
};
|
|
8011
|
+
res.writeHead(status, out);
|
|
8012
|
+
res.end(transformed);
|
|
8013
|
+
});
|
|
8014
|
+
upstream.on("error", () => {
|
|
8015
|
+
try {
|
|
8016
|
+
res.destroy();
|
|
8017
|
+
} catch {}
|
|
8018
|
+
});
|
|
8019
|
+
return;
|
|
8020
|
+
}
|
|
8021
|
+
if (method === "workspace.list" && status === 200 && ct.includes("application/json")) {
|
|
8022
|
+
const chunks = [];
|
|
8023
|
+
upstream.on("data", (c) => chunks.push(c));
|
|
8024
|
+
upstream.on("end", () => {
|
|
8025
|
+
const transformed = filterWorkspaceList(Buffer.concat(chunks).toString("utf-8"), allowWorkspaces);
|
|
8026
|
+
const out = {
|
|
8027
|
+
...upstream.headers,
|
|
8028
|
+
"content-length": Buffer.byteLength(transformed)
|
|
8029
|
+
};
|
|
8030
|
+
res.writeHead(status, out);
|
|
8031
|
+
res.end(transformed);
|
|
8032
|
+
});
|
|
8033
|
+
upstream.on("error", () => {
|
|
8034
|
+
try {
|
|
8035
|
+
res.destroy();
|
|
8036
|
+
} catch {}
|
|
8037
|
+
});
|
|
8038
|
+
return;
|
|
8039
|
+
}
|
|
8040
|
+
res.writeHead(status, upstream.headers);
|
|
7548
8041
|
upstream.pipe(res);
|
|
8042
|
+
upstream.on("error", () => {
|
|
8043
|
+
try {
|
|
8044
|
+
res.destroy();
|
|
8045
|
+
} catch {}
|
|
8046
|
+
});
|
|
7549
8047
|
});
|
|
7550
8048
|
target.on("error", () => {
|
|
7551
|
-
|
|
7552
|
-
|
|
8049
|
+
try {
|
|
8050
|
+
res.writeHead(502, { "content-type": "text/plain" });
|
|
8051
|
+
res.end("502 Bad Gateway: dsh web 主端口不可达");
|
|
8052
|
+
} catch {}
|
|
7553
8053
|
});
|
|
7554
|
-
req.
|
|
8054
|
+
req.on("error", () => {
|
|
8055
|
+
try {
|
|
8056
|
+
target.destroy();
|
|
8057
|
+
} catch {}
|
|
8058
|
+
});
|
|
8059
|
+
res.on("error", () => {
|
|
8060
|
+
try {
|
|
8061
|
+
target.destroy();
|
|
8062
|
+
} catch {}
|
|
8063
|
+
});
|
|
8064
|
+
if (bodyOverride !== void 0) target.end(bodyOverride);
|
|
8065
|
+
else req.pipe(target);
|
|
7555
8066
|
};
|
|
8067
|
+
const lastTotpCounter = /* @__PURE__ */ new Map();
|
|
7556
8068
|
const server = createServer((req, res) => {
|
|
7557
8069
|
const url = new URL(req.url ?? "/", `http://${host}:${port}`);
|
|
7558
|
-
if (
|
|
8070
|
+
if (req.method === "POST" && url.pathname === "/serenity/logout") {
|
|
8071
|
+
const cookie = cookieValue(req.headers.cookie, cookieName);
|
|
8072
|
+
if (cookie !== void 0) revokeToken(cookie);
|
|
8073
|
+
res.writeHead(302, {
|
|
8074
|
+
location: "/",
|
|
8075
|
+
"set-cookie": `${cookieName}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0${cookieSecure ? "; Secure" : ""}`
|
|
8076
|
+
});
|
|
8077
|
+
res.end();
|
|
8078
|
+
return;
|
|
8079
|
+
}
|
|
8080
|
+
const session = authed(req);
|
|
8081
|
+
if (session !== void 0) {
|
|
8082
|
+
if (req.method === "POST" && url.pathname === "/api/workspace.create") {
|
|
8083
|
+
if (!allowWorkspaceCreate) {
|
|
8084
|
+
readBody(req, 131072).then((body) => {
|
|
8085
|
+
let rpcId = "unknown";
|
|
8086
|
+
try {
|
|
8087
|
+
const parsed = JSON.parse(body);
|
|
8088
|
+
if (typeof parsed.rpcId === "string") rpcId = parsed.rpcId;
|
|
8089
|
+
} catch {}
|
|
8090
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
8091
|
+
res.end(JSON.stringify({
|
|
8092
|
+
type: "server-response",
|
|
8093
|
+
rpcId,
|
|
8094
|
+
result: {
|
|
8095
|
+
ok: false,
|
|
8096
|
+
error: {
|
|
8097
|
+
code: "forbidden",
|
|
8098
|
+
message: "workspace creation disabled for external access",
|
|
8099
|
+
details: {}
|
|
8100
|
+
}
|
|
8101
|
+
}
|
|
8102
|
+
}));
|
|
8103
|
+
}).catch(() => {
|
|
8104
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
8105
|
+
res.end(JSON.stringify({
|
|
8106
|
+
type: "server-response",
|
|
8107
|
+
rpcId: "unknown",
|
|
8108
|
+
result: {
|
|
8109
|
+
ok: false,
|
|
8110
|
+
error: {
|
|
8111
|
+
code: "bad-request",
|
|
8112
|
+
message: "body too large",
|
|
8113
|
+
details: {}
|
|
8114
|
+
}
|
|
8115
|
+
}
|
|
8116
|
+
}));
|
|
8117
|
+
});
|
|
8118
|
+
return;
|
|
8119
|
+
}
|
|
8120
|
+
readBody(req, 131072).then((body) => {
|
|
8121
|
+
let rpcId = "unknown";
|
|
8122
|
+
let path;
|
|
8123
|
+
try {
|
|
8124
|
+
const parsed = JSON.parse(body);
|
|
8125
|
+
if (typeof parsed.rpcId === "string") rpcId = parsed.rpcId;
|
|
8126
|
+
if (typeof parsed.payload?.path === "string") path = parsed.payload.path;
|
|
8127
|
+
} catch {}
|
|
8128
|
+
if (!workspaceAllowed(allowWorkspaces, path)) {
|
|
8129
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
8130
|
+
res.end(workspaceDenyResponse(rpcId));
|
|
8131
|
+
return;
|
|
8132
|
+
}
|
|
8133
|
+
proxy(req, res, body);
|
|
8134
|
+
}).catch(() => {
|
|
8135
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
8136
|
+
res.end(JSON.stringify({
|
|
8137
|
+
type: "server-response",
|
|
8138
|
+
rpcId: "unknown",
|
|
8139
|
+
result: {
|
|
8140
|
+
ok: false,
|
|
8141
|
+
error: {
|
|
8142
|
+
code: "bad-request",
|
|
8143
|
+
message: "body too large",
|
|
8144
|
+
details: {}
|
|
8145
|
+
}
|
|
8146
|
+
}
|
|
8147
|
+
}));
|
|
8148
|
+
});
|
|
8149
|
+
return;
|
|
8150
|
+
}
|
|
8151
|
+
if (req.method === "PUT" && url.pathname === "/serenity/config") {
|
|
8152
|
+
if (!originAllowed(req.headers.origin, req.headers.host ?? "", mainPort)) {
|
|
8153
|
+
console.warn(`[serenity-hooks] 拒绝跨源配置写(Origin=${String(req.headers.origin)}):session=${session.user}`);
|
|
8154
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
8155
|
+
res.end(JSON.stringify({ error: "cross-origin config write rejected" }));
|
|
8156
|
+
return;
|
|
8157
|
+
}
|
|
8158
|
+
}
|
|
7559
8159
|
proxy(req, res);
|
|
7560
8160
|
return;
|
|
7561
8161
|
}
|
|
7562
8162
|
if (req.method === "POST" && url.pathname === "/serenity/login") {
|
|
7563
8163
|
let body = "";
|
|
8164
|
+
req.on("error", () => {
|
|
8165
|
+
try {
|
|
8166
|
+
res.destroy();
|
|
8167
|
+
} catch {}
|
|
8168
|
+
});
|
|
8169
|
+
res.on("error", () => {
|
|
8170
|
+
try {
|
|
8171
|
+
req.destroy();
|
|
8172
|
+
} catch {}
|
|
8173
|
+
});
|
|
7564
8174
|
req.on("data", (chunk) => {
|
|
7565
8175
|
body += chunk.toString("utf-8");
|
|
7566
8176
|
});
|
|
@@ -7568,86 +8178,215 @@ function startGateway(config, getAccounts) {
|
|
|
7568
8178
|
const params = new URLSearchParams(body);
|
|
7569
8179
|
const user = params.get("user") ?? "";
|
|
7570
8180
|
const password = params.get("password") ?? "";
|
|
7571
|
-
|
|
7572
|
-
|
|
8181
|
+
const code = params.get("code") ?? "";
|
|
8182
|
+
const remote = req.socket.remoteAddress ?? "unknown";
|
|
8183
|
+
const cookieCsrf = cookieValue(req.headers.cookie, "serenity_csrf");
|
|
8184
|
+
const formCsrf = csrfFromRequest(req, params);
|
|
8185
|
+
if (cookieCsrf === void 0 || formCsrf === null || !safeEqual(cookieCsrf, formCsrf)) {
|
|
8186
|
+
console.warn(`[serenity-hooks] 登录拒绝(CSRF 校验失败):user=${user} ip=${remote}`);
|
|
8187
|
+
res.writeHead(403, { "content-type": "text/html; charset=utf-8" });
|
|
8188
|
+
res.end(loginPageHtml("会话校验失败,请刷新页面重试"));
|
|
8189
|
+
return;
|
|
8190
|
+
}
|
|
8191
|
+
const lockedRemaining = accountLockRemaining(user);
|
|
8192
|
+
if (lockedRemaining > 0) {
|
|
8193
|
+
console.warn(`[serenity-hooks] 登录拒绝(账号锁定中):user=${user} ip=${remote} 剩余=${Math.ceil(lockedRemaining / 6e4)}min`);
|
|
8194
|
+
res.writeHead(429, {
|
|
8195
|
+
"content-type": "text/html; charset=utf-8",
|
|
8196
|
+
"retry-after": String(Math.ceil(lockedRemaining / 1e3))
|
|
8197
|
+
});
|
|
8198
|
+
res.end(loginPageHtml(`账号已锁定,请 ${Math.ceil(lockedRemaining / 6e4)} 分钟后再试`));
|
|
8199
|
+
return;
|
|
8200
|
+
}
|
|
8201
|
+
const accounts = getAccounts();
|
|
8202
|
+
const account = accounts.find((a) => a.user === user);
|
|
8203
|
+
const passOk = account !== void 0 && verifyGatewayLogin(accounts, user, password);
|
|
8204
|
+
let totpOk = true;
|
|
8205
|
+
if (totpEnabled && passOk && account !== void 0 && typeof account.totpSecret === "string" && account.totpSecret !== "") {
|
|
8206
|
+
const hit = verifyTotpCode(account.totpSecret, code);
|
|
8207
|
+
totpOk = hit !== null;
|
|
8208
|
+
if (totpOk && hit !== null && lastTotpCounter.get(account.id) === hit) totpOk = false;
|
|
8209
|
+
if (totpOk && hit !== null) lastTotpCounter.set(account.id, hit);
|
|
8210
|
+
}
|
|
8211
|
+
if (passOk && totpOk) {
|
|
8212
|
+
resetFailState(user);
|
|
8213
|
+
const token = issueToken(user);
|
|
7573
8214
|
res.writeHead(302, {
|
|
7574
8215
|
location: "/",
|
|
7575
|
-
"set-cookie": `${cookieName}=${token}; HttpOnly; SameSite=Strict; Path
|
|
8216
|
+
"set-cookie": `${cookieName}=${token}; HttpOnly; SameSite=Strict; Path=/${cookieSecure ? "; Secure" : ""}`
|
|
7576
8217
|
});
|
|
7577
8218
|
res.end();
|
|
8219
|
+
console.log(`[serenity-hooks] 登录成功: user=${user} ip=${remote}`);
|
|
7578
8220
|
return;
|
|
7579
8221
|
}
|
|
8222
|
+
const lockMs = recordLoginFailure(user);
|
|
8223
|
+
console.warn(`[serenity-hooks] 登录失败: user=${user} ip=${remote} reason=${!passOk ? "bad-password" : "bad-totp"}${lockMs > 0 ? ` 锁定=${Math.ceil(lockMs / 6e4)}min` : ""}`);
|
|
8224
|
+
const msg = lockMs > 0 ? `尝试过多,账号已锁定 ${Math.ceil(lockMs / 6e4)} 分钟` : passOk ? "验证码错误" : "用户名或密码错误";
|
|
7580
8225
|
res.writeHead(401, { "content-type": "text/html; charset=utf-8" });
|
|
7581
|
-
res.end(loginPageHtml(
|
|
8226
|
+
res.end(loginPageHtml(msg));
|
|
7582
8227
|
});
|
|
7583
8228
|
return;
|
|
7584
8229
|
}
|
|
7585
|
-
|
|
7586
|
-
res.
|
|
8230
|
+
const csrf = newCsrfToken();
|
|
8231
|
+
res.writeHead(200, {
|
|
8232
|
+
"content-type": "text/html; charset=utf-8",
|
|
8233
|
+
"set-cookie": `serenity_csrf=${csrf}; HttpOnly; SameSite=Strict; Path=/${cookieSecure ? "; Secure" : ""}`
|
|
8234
|
+
});
|
|
8235
|
+
res.end(loginPageHtml("", csrf));
|
|
7587
8236
|
});
|
|
7588
8237
|
server.on("upgrade", (req, socket, head) => {
|
|
7589
8238
|
if (!authed(req)) {
|
|
7590
8239
|
socket.destroy();
|
|
7591
8240
|
return;
|
|
7592
8241
|
}
|
|
8242
|
+
socket.on("error", () => {
|
|
8243
|
+
try {
|
|
8244
|
+
usocket?.destroy();
|
|
8245
|
+
} catch {}
|
|
8246
|
+
});
|
|
8247
|
+
let usocket;
|
|
7593
8248
|
const upstream = request({
|
|
7594
8249
|
host: "127.0.0.1",
|
|
7595
8250
|
port: mainPort,
|
|
7596
8251
|
path: req.url ?? "/",
|
|
7597
8252
|
method: req.method,
|
|
7598
|
-
headers:
|
|
7599
|
-
|
|
7600
|
-
|
|
8253
|
+
headers: buildProxyHeaders(req.headers, mainPort)
|
|
8254
|
+
});
|
|
8255
|
+
upstream.on("upgrade", (ures, usock, uhead) => {
|
|
8256
|
+
usocket = usock;
|
|
8257
|
+
usock.on("error", () => {
|
|
8258
|
+
try {
|
|
8259
|
+
socket.destroy();
|
|
8260
|
+
} catch {}
|
|
8261
|
+
});
|
|
8262
|
+
const statusLine = `HTTP/1.1 ${ures.statusCode ?? 101} ${ures.statusMessage ?? "Switching Protocols"}\r\n`;
|
|
8263
|
+
const headerLines = Object.entries(ures.headers).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : String(v)}\r\n`).join("");
|
|
8264
|
+
try {
|
|
8265
|
+
socket.write(statusLine + headerLines + "\r\n");
|
|
8266
|
+
} catch {
|
|
8267
|
+
usock.destroy();
|
|
8268
|
+
socket.destroy();
|
|
8269
|
+
return;
|
|
7601
8270
|
}
|
|
8271
|
+
usock.pipe(socket);
|
|
8272
|
+
socket.pipe(usock);
|
|
8273
|
+
if (head.length > 0) usock.write(head);
|
|
8274
|
+
if (uhead.length > 0) socket.write(uhead);
|
|
7602
8275
|
});
|
|
7603
|
-
upstream.on("
|
|
7604
|
-
|
|
7605
|
-
|
|
7606
|
-
|
|
7607
|
-
|
|
8276
|
+
upstream.on("response", (ures) => {
|
|
8277
|
+
const chunks = [];
|
|
8278
|
+
ures.on("data", (c) => chunks.push(c));
|
|
8279
|
+
ures.on("end", () => {
|
|
8280
|
+
const body = Buffer.concat(chunks);
|
|
8281
|
+
const statusLine = `HTTP/1.1 ${ures.statusCode ?? 502} ${ures.statusMessage ?? ""}\r\n`;
|
|
8282
|
+
const headerLines = Object.entries(ures.headers).filter(([k]) => ![
|
|
8283
|
+
"transfer-encoding",
|
|
8284
|
+
"connection",
|
|
8285
|
+
"upgrade"
|
|
8286
|
+
].includes(k.toLowerCase())).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : String(v)}\r\n`).join("");
|
|
8287
|
+
try {
|
|
8288
|
+
socket.write(statusLine + headerLines + `content-length: ${body.length}\r\n\r\n`);
|
|
8289
|
+
socket.write(body);
|
|
8290
|
+
socket.end();
|
|
8291
|
+
} catch {
|
|
8292
|
+
socket.destroy();
|
|
8293
|
+
}
|
|
8294
|
+
});
|
|
8295
|
+
ures.on("error", () => socket.destroy());
|
|
7608
8296
|
});
|
|
7609
8297
|
upstream.on("error", () => socket.destroy());
|
|
7610
8298
|
upstream.end();
|
|
7611
8299
|
});
|
|
7612
|
-
|
|
8300
|
+
const listenWithRetry = (attempt = 0) => {
|
|
8301
|
+
const onError = (err) => {
|
|
8302
|
+
if (err.code === "EADDRINUSE" && attempt < 10) {
|
|
8303
|
+
console.warn(`[serenity-hooks] gateway 端口 ${port} 被占用(${err.code}),1s 后重试(${attempt + 1}/10)…`);
|
|
8304
|
+
setTimeout(() => listenWithRetry(attempt + 1), 1e3);
|
|
8305
|
+
return;
|
|
8306
|
+
}
|
|
8307
|
+
console.warn(`[serenity-hooks] gateway 监听 ${host}:${port} 失败: ${err.message ?? String(err)}`);
|
|
8308
|
+
};
|
|
8309
|
+
server.removeAllListeners("error");
|
|
8310
|
+
server.on("error", onError);
|
|
8311
|
+
server.removeAllListeners("clientError");
|
|
8312
|
+
server.on("clientError", (_err, socket) => {
|
|
8313
|
+
try {
|
|
8314
|
+
socket.destroy();
|
|
8315
|
+
} catch {}
|
|
8316
|
+
});
|
|
8317
|
+
server.listen(port, host);
|
|
8318
|
+
};
|
|
8319
|
+
listenWithRetry();
|
|
7613
8320
|
return {
|
|
7614
8321
|
server,
|
|
7615
8322
|
dispose: () => {
|
|
7616
|
-
tokens.clear();
|
|
7617
8323
|
server.close();
|
|
7618
8324
|
}
|
|
7619
8325
|
};
|
|
7620
8326
|
}
|
|
7621
|
-
/** 注册 gateway(index.ts apply
|
|
7622
|
-
|
|
8327
|
+
/** 注册 gateway(index.ts apply 调用)。
|
|
8328
|
+
* 归属原则(v1.22):gateway 是 plugin 全局能力——enabled 开关读 DSH settings
|
|
8329
|
+
* (readSimpleSettings().gatewayEnabled),host/port/accounts 读 plugin 全局文件
|
|
8330
|
+
* (readAdvancedSettings(),~/.dsh/serenity-hooks.json)。**不依赖任何具体 CCC**。
|
|
8331
|
+
* 幂等 sync:仅当 配置/端口/开关 变化时重建监听器。
|
|
8332
|
+
* 事件驱动:config-updated(/serenity/config PUT 后 emit)+ DSH settings 变化(onChange emit)触发重新 sync。
|
|
8333
|
+
*/
|
|
8334
|
+
function registerGateway(ctx) {
|
|
7623
8335
|
let current = null;
|
|
8336
|
+
let lastSig = null;
|
|
7624
8337
|
const sync = () => {
|
|
7625
8338
|
try {
|
|
8339
|
+
const webServer = ctx.get?.("webServer");
|
|
8340
|
+
if (!webServer?.port) return;
|
|
8341
|
+
const enabled = readSimpleSettings().gatewayEnabled;
|
|
8342
|
+
const settings = readAdvancedSettings();
|
|
8343
|
+
const sig = `${enabled}|${settings.gateway.host}|${settings.gateway.port}|${settings.gateway.accounts.length}|${settings.gateway.workspaces.join(",")}|${webServer.port}`;
|
|
8344
|
+
if (sig === lastSig) return;
|
|
8345
|
+
lastSig = sig;
|
|
7626
8346
|
if (current) {
|
|
7627
8347
|
current.dispose();
|
|
7628
8348
|
current = null;
|
|
7629
8349
|
}
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
const settings = readAdvancedSettings(root);
|
|
7635
|
-
if (!settings.gateway.enabled) return;
|
|
7636
|
-
if (settings.gateway.accounts.length === 0) return;
|
|
8350
|
+
if (!enabled) {
|
|
8351
|
+
console.log("[serenity-hooks] gateway 已停止(gatewayEnabled=false,可在 dsh 设置面板开启)");
|
|
8352
|
+
return;
|
|
8353
|
+
}
|
|
7637
8354
|
current = startGateway({
|
|
7638
8355
|
host: settings.gateway.host,
|
|
7639
8356
|
port: settings.gateway.port,
|
|
7640
8357
|
cookieName: "serenity_session",
|
|
7641
8358
|
mainPort: webServer.port,
|
|
7642
|
-
loginDelayMs: 0
|
|
7643
|
-
|
|
7644
|
-
|
|
8359
|
+
loginDelayMs: 0,
|
|
8360
|
+
allowWorkspaces: settings.gateway.workspaces,
|
|
8361
|
+
cookieSecure: settings.gateway.cookieSecure === true,
|
|
8362
|
+
allowWorkspaceCreate: settings.gateway.allowWorkspaceCreate !== false,
|
|
8363
|
+
totpEnabled: settings.gateway.totpEnabled === true
|
|
8364
|
+
}, () => readAdvancedSettings().gateway.accounts);
|
|
8365
|
+
const accounts = settings.gateway.accounts.length;
|
|
8366
|
+
const wsNote = settings.gateway.workspaces.length === 0 ? "" : `;工作区白名单 ${settings.gateway.workspaces.length} 条`;
|
|
8367
|
+
console.log(`[serenity-hooks] gateway 已启动: http://${settings.gateway.host}:${settings.gateway.port} → 127.0.0.1:${webServer.port}` + (accounts === 0 ? "(⚠️ 未配置账号,登录页将提示)" : `(${accounts} 个账号)`) + wsNote);
|
|
7645
8368
|
} catch (err) {
|
|
7646
|
-
console.warn(`[serenity-hooks] gateway
|
|
8369
|
+
console.warn(`[serenity-hooks] gateway 同步失败: ${String(err?.message ?? err)}`);
|
|
7647
8370
|
}
|
|
7648
8371
|
};
|
|
7649
8372
|
sync();
|
|
7650
|
-
ctx.on("session
|
|
8373
|
+
ctx.on("agent/session-start", (payload) => {
|
|
8374
|
+
try {
|
|
8375
|
+
const cwd = payload.agent?.session?.header?.cwd;
|
|
8376
|
+
if (cwd) {
|
|
8377
|
+
const root = findSerenityRoot(cwd);
|
|
8378
|
+
if (root && migrateLegacyLocalstore(root)) console.log(`[serenity-hooks] ✓ 已迁移 serenityAdvanced(CCC localstore → plugin 全局文件)`);
|
|
8379
|
+
}
|
|
8380
|
+
} catch {}
|
|
8381
|
+
sync();
|
|
8382
|
+
});
|
|
8383
|
+
ctx.on("serenity/config-updated", () => {
|
|
8384
|
+
lastSig = null;
|
|
8385
|
+
sync();
|
|
8386
|
+
});
|
|
8387
|
+
ctx.on("serenity/settings-changed", () => {
|
|
8388
|
+
sync();
|
|
8389
|
+
});
|
|
7651
8390
|
}
|
|
7652
8391
|
//#endregion
|
|
7653
8392
|
//#region src/index.ts
|
|
@@ -7661,7 +8400,8 @@ const inject = [
|
|
|
7661
8400
|
"skills",
|
|
7662
8401
|
"agentLoop",
|
|
7663
8402
|
"agents",
|
|
7664
|
-
"systemPrompt"
|
|
8403
|
+
"systemPrompt",
|
|
8404
|
+
"sessionProjections"
|
|
7665
8405
|
];
|
|
7666
8406
|
const Config = z.object({
|
|
7667
8407
|
serenityConfigPaths: z.array(z.string()).default([...DEFAULT_SERENITY_CONFIG_PATHS]),
|
|
@@ -7712,7 +8452,8 @@ function apply(ctx, config) {
|
|
|
7712
8452
|
});
|
|
7713
8453
|
if (config.api) registerStatusApi(ctx, { configPaths: config.serenityConfigPaths });
|
|
7714
8454
|
registerSettingsSection(ctx, config);
|
|
7715
|
-
registerGateway(ctx
|
|
8455
|
+
registerGateway(ctx);
|
|
8456
|
+
registerRebuildTurnHook(ctx);
|
|
7716
8457
|
if (config.env) registerEnv(ctx);
|
|
7717
8458
|
if (config.opencodeSkills) registerOpencodeSkills(ctx);
|
|
7718
8459
|
registerBootstrap(ctx);
|