@shgroup/dsh-serenity-hooks 1.30.11 → 1.30.13

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/cordis.patch.yml CHANGED
@@ -1,8 +1,28 @@
1
1
  # dsh-serenity-hooks bundle 层(经 package.json `dsh.bundle.patch` 声明)
2
2
  # 装入 profile(`dsh plugin --profile <name> add @shgroup/dsh-serenity-hooks`)
3
3
  # 后由 profile-boot 组合:行 name 即包名,Loader 从 profile node_modules 解析。
4
+ #
5
+ # patch 语义(宿主 app-boot `applyEntryPatches` 实证,0.1.2-rc.1):
6
+ # - `insert:` 追加条目;无 id 追加到根,有 id 追加到该 group 的 config
7
+ # - 无 insert 的行按 id 定向覆盖字段(`config` 为**整体替换**,非深合并)
8
+ # - 未命中的 patch 只告警跳过(宿主换版本时不会炸)
9
+ # - 各 bundle 的 patch 与本 profile 的 cordis.patch.yml 被**展平成一个列表**按序应用
10
+ # (本包在 bundles 末尾 → 其 patch 在所有宿主 bundle 之后生效)
4
11
  - insert:
5
12
  - id: serenity-hooks
6
13
  name: '@shgroup/dsh-serenity-hooks'
7
14
  config:
8
15
  serenityConfigPaths: ['.dsh/serenity.json', '.opencode/serenity.json']
16
+
17
+ # ── v1.30.12(S142 用户拍板 L3):web_fetch 在 fake-ip/TUN 网络下恒失败 ────────────
18
+ # 宿主内置 provider `@deepseek-ai/dsh-web-fetch-http` 用 ipaddr.js 判「公网单播」,
19
+ # 而本网络 DNS 走 Clash fake-ip(域名→198.18.0.0/15,实证 cdn.jsdelivr.net→198.18.1.85)
20
+ # → 恒抛 WEB_BLOCKED_URL,且该包 Config 无开关(只有 5 个配额字段)。
21
+ # 屏蔽:禁用宿主内置 provider,改由本插件注册的 provider 接管**同一 id**(`http`,
22
+ # HttpFetchProvider 的实例字段 LOCAL_FETCH_PROVIDER_ID)——宿主 `web` 的既有配置
23
+ # `fetchProvider: http` 因此无需改动(也就不会覆盖 searchProvider)。
24
+ # 需要恢复宿主原行为:把下面两行删除或改 `disabled: false`,并同时关掉插件配置
25
+ # `serenity-hooks.webFetch.enabled`(否则会 WEB_DUPLICATE_PROVIDER)。
26
+ - id: web-fetch-http
27
+ name: '@deepseek-ai/dsh-web-fetch-http'
28
+ disabled: true
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-serenity-hooks",
3
- "version": "1.30.11",
3
+ "version": "1.30.13",
4
4
  "main": "lib/index.js",
5
5
  "description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——10 个工具(container_fs/logbook/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/autopilot-trajectory)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 工作日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/自主巡航。适配 DSH 0.1.2-rc.1(deepseek-ai/deepseek-harness)。",
6
6
  "engines": {
@@ -45,5 +45,9 @@ function hostWebServer(ctx) {
45
45
  function hostSettings(ctx) {
46
46
  return hostInjected(ctx, "settings");
47
47
  }
48
+ /** `ctx.web`(injected;v1.30.12 web_fetch provider 注册通道) */
49
+ function hostWeb(ctx) {
50
+ return hostInjected(ctx, "web");
51
+ }
48
52
  //#endregion
49
- export { hostSettings as a, hostSessions as i, hostInjected as n, hostWebServer as o, hostService as r, hostAgents as t };
53
+ export { hostSettings as a, hostSessions as i, hostInjected as n, hostWeb as o, hostService as r, hostWebServer as s, hostAgents as t };
@@ -41,6 +41,11 @@ export interface HostWebServer {
41
41
  export interface HostSettings {
42
42
  installSection?: (...args: unknown[]) => unknown;
43
43
  }
44
+ /** `ctx.web`(injected;v1.30.12:fetch provider 注册通道) */
45
+ export interface HostWeb {
46
+ registerFetchProvider?: (provider: unknown) => unknown;
47
+ registerSearchProvider?: (provider: unknown) => unknown;
48
+ }
44
49
  /** `ctx.sessions`(injected) */
45
50
  export declare function hostSessions(ctx: unknown): HostSessions | undefined;
46
51
  /** `ctx.agents`(injected) */
@@ -49,5 +54,7 @@ export declare function hostAgents(ctx: unknown): HostAgents | undefined;
49
54
  export declare function hostWebServer(ctx: unknown): HostWebServer | undefined;
50
55
  /** `ctx.settings`(injected;提供 settings 面板装配通道) */
51
56
  export declare function hostSettings(ctx: unknown): HostSettings | undefined;
57
+ /** `ctx.web`(injected;v1.30.12 web_fetch provider 注册通道) */
58
+ export declare function hostWeb(ctx: unknown): HostWeb | undefined;
52
59
  /** 会话 cwd 列表(live 会话;形状不符时返回空数组而非抛错) */
53
60
  export declare function hostSessionCwds(ctx: unknown): string[];
package/lib/index.d.ts CHANGED
@@ -60,6 +60,10 @@ export interface Config {
60
60
  enabled?: boolean;
61
61
  httpPort?: number;
62
62
  };
63
+ /** v1.30.12 web_fetch provider 接管(fake-ip / TUN 网络:宿主内置 provider 判「非公网」恒拒) */
64
+ webFetch?: {
65
+ enabled?: boolean;
66
+ };
63
67
  }
64
68
  export declare const Config: z<Config>;
65
69
  export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
2
  import { a as findSerenityRoot, c as matchBlacklist, d as readCccName$2, f as readHandymanConfig, i as findGitRoot, l as pathInside, m as resolveInside, n as SAFE_MODE_MARKER, o as isSafeModeOn, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-DAsSHsub.js";
3
3
  import { a as resolveRoleSystemPrompt, i as readSkiffRoles, l as systemPromptSource, r as isSkiffSessionId, s as roleToolWhitelist } from "./skiff-role-DlrbHPLD.js";
4
- import { C as newStopToken, D as writeFailedStatus, E as splitModel, O as writeProgress, S as listActiveHandymen, T as requireWhitelistedModel, _ as workspaceTrajectoryLine, a as startSkiffDebugServer, b as buildRoundPrompt, c as askSkiff, d as getSkiffAgent, f as skiffMsmGate, g as unregisterSkiffSession, h as skiffTrajectoryEnabled, k as skiffRoleFor, l as createSkiffAgent, m as skiffSessionSnapshot, n as jscSafeJsonText, o as stopSkiffDebugServer, p as skiffSessionInfo, r as renderSkiffMarkdown, s as stripThink, t as discoverCccs, u as ensureSkiffSession, v as waitAgentIdle, w as readProgress, x as handymanProgressPaths, y as HANDYMAN_GUIDE } from "./skiff-debug-TONJlpgF.js";
5
- import { i as hostSessions, n as hostInjected, o as hostWebServer, r as hostService, t as hostAgents } from "./access-CdL6BAYj.js";
4
+ import { A as skiffRoleFor, C as listActiveHandymen, D as splitModel, E as requireWhitelistedModel, O as writeFailedStatus, S as handymanProgressPaths, T as readProgress, _ as unregisterSkiffSession, a as startSkiffDebugServer, b as HANDYMAN_GUIDE, c as askSkiff, d as ensureWorkspacePromptSection, f as getSkiffAgent, g as skiffTrajectoryEnabled, h as skiffSessionSnapshot, k as writeProgress, l as createSkiffAgent, m as skiffSessionInfo, n as jscSafeJsonText, o as stopSkiffDebugServer, p as skiffMsmGate, r as renderSkiffMarkdown, s as stripThink, t as discoverCccs, u as ensureSkiffSession, v as workspaceTrajectoryLine, w as newStopToken, x as buildRoundPrompt, y as waitAgentIdle } from "./skiff-debug-DYXDdzu3.js";
5
+ import { i as hostSessions, n as hostInjected, o as hostWeb, r as hostService, s as hostWebServer, t as hostAgents } from "./access-dU_vG1q8.js";
6
6
  import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, h as runLocalStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-BD_yYAZz.js";
7
- import { C as summarize, S as showSession, _ as readActiveSessionMd, a as archiveSessions, b as sessionsRoot, c as createSession, d as findSession, f as getActiveSessionInfo, g as qaCheck, h as parseSessionContextFromEvents, i as SESSION_ACTIONS, l as extractSessionMdPathFromText, m as listSessions, n as readLastBound, o as clearActiveSessionInfo, p as healthCheck, r as DEFAULT_SESSION_SCOPE, s as closeSession, t as appendBound, u as findLatestActiveSessionMd, v as resolveSessionByTitle, w as useSession, x as setActiveSessionInfo, y as sessionEvents } from "./session-bound-D2ANqVn-.js";
8
- import { n as registerSettingsSection, t as readSimpleSettings } from "./settings-section-BPk0e1du.js";
7
+ import { C as useSession, S as summarize, _ as resolveSessionByTitle, a as archiveSessions, b as setActiveSessionInfo, c as createSession, d as getActiveSessionInfo, f as healthCheck, g as readActiveSessionMd, h as qaCheck, i as SESSION_ACTIONS, l as extractSessionMdPathFromText, m as parseSessionContextFromEvents, n as readLastBound, o as clearActiveSessionInfo, p as listSessions, r as DEFAULT_SESSION_SCOPE, s as closeSession, t as appendBound, u as findSession, v as sessionEvents, x as showSession, y as sessionsRoot } from "./session-bound-BpTayaIS.js";
8
+ import { n as registerSettingsSection, t as readSimpleSettings } from "./settings-section-CP4uMJf_.js";
9
9
  import { a as markdownToPlainText, c as sniffImageExt, i as getUpdates, n as downloadMedia, o as sendTextMessage, r as getConfig, s as sendTyping, t as TypingStatus } from "./weixin-api-BMpljodH.js";
10
10
  import z from "@deepseek-ai/schemastery";
11
11
  import { defineTool } from "@deepseek-ai/dsh-tools";
@@ -21,6 +21,8 @@ import { createHmac, randomBytes, randomUUID, scryptSync, timingSafeEqual } from
21
21
  import { createServer, request } from "node:http";
22
22
  import * as zlib from "node:zlib";
23
23
  import { deriveEventMessage } from "@deepseek-ai/dsh-session";
24
+ import { lookup } from "node:dns/promises";
25
+ import { isIP } from "node:net";
24
26
  //#region src/fs-ops.ts
25
27
  /**
26
28
  * fs-ops.ts — container_fs 纯操作层(cc_fs → container_fs,v1.30;零 DSH 依赖,可独立单测)
@@ -914,6 +916,17 @@ const HOST_SERVICES = [
914
916
  impact: "设置面板不安装 → 所有开关静默 no-op",
915
917
  required: true
916
918
  },
919
+ {
920
+ id: "web",
921
+ name: "web",
922
+ access: "injected",
923
+ members: [{
924
+ name: "registerFetchProvider",
925
+ kind: "function"
926
+ }],
927
+ impact: "web_fetch provider 无法注册 → fake-ip 网络下 web_fetch 不可用",
928
+ required: false
929
+ },
917
930
  {
918
931
  id: "systemPrompt",
919
932
  name: "systemPrompt",
@@ -6465,7 +6478,7 @@ function registerStatusApi(ctx, opts = {}) {
6465
6478
  workspace: url.searchParams.get("workspace") ?? void 0
6466
6479
  });
6467
6480
  const root = findSerenityRoot(workspace) ?? "";
6468
- const { discoverCccs } = await import("./skiff-debug-TONJlpgF.js").then((n) => n.i);
6481
+ const { discoverCccs } = await import("./skiff-debug-DYXDdzu3.js").then((n) => n.i);
6469
6482
  sendJson$2(res, 200, { cccs: await discoverCccs(ctx, root) });
6470
6483
  } catch (err) {
6471
6484
  sendJson$2(res, 400, { error: err.message ?? String(err) });
@@ -6495,7 +6508,7 @@ function registerStatusApi(ctx, opts = {}) {
6495
6508
  return;
6496
6509
  }
6497
6510
  const settings = readAdvancedSettings();
6498
- const { readSimpleSettings } = await import("./settings-section-BPk0e1du.js").then((n) => n.r);
6511
+ const { readSimpleSettings } = await import("./settings-section-CP4uMJf_.js").then((n) => n.r);
6499
6512
  const simple = readSimpleSettings();
6500
6513
  const allowed = settings.publicAsk.allowed;
6501
6514
  const port = simple.acpHttpPort ?? 3100;
@@ -7949,7 +7962,17 @@ function sessionNameFromMdPath(mdPath) {
7949
7962
  /**
7950
7963
  * 稳固的 SESSION.md 定位(多层候选 + 存在性校验,**绝不输出虚假路径**):
7951
7964
  * ① 内存活跃会话(本会话显式 use 过)→ ② events 恢复(use 标记 + 重建锚点规范行,进程重启后)
7952
- * → ③ surface 首条锚点(events 异常时兜底)→ ④ AGENT_SESSIONS 约定回退(最新未完成活动目录)。
7965
+ * → ②b **权威绑定**(`AGENT_SESSIONS/.bindings.json`,本会话持久绑定)→ ③ surface 首条锚点
7966
+ * (events 异常时兜底)。
7967
+ *
7968
+ * v1.30.13(S142 用户"微信桥 + skiff 会话锚定要准确",诊断 D4):**移除**原 ④「全局最新未完成
7969
+ * 会话」(`findLatestActiveSessionMd`)。理由(R↓):④ 是**跨轨迹**猜测——候选①②③全空时,
7970
+ * 它会把 rebuild 静默接到 AGENT_SESSIONS 里最新的另一个会话(临时 skiff 会话 persistent=false、
7971
+ * 重启后首条消息前、Danica 新会话都命中该形态),而"接错轨迹"比重建失败危险得多
7972
+ * (错误轨迹被继续写入 + 正确轨迹静默丢失)。现在全部候选失败 → 返回 null → 调用方
7973
+ * 报错引导用户显式 `logbook use`(响亮失败优于静默接错)。
7974
+ * 备选:④ 仅在"同 scope 确无绑定"时兜底——同样会接错轨迹,故不采纳。
7975
+ *
7953
7976
  * 每候选 resolve 后 existsSync 校验(相对路径按 root 解析);全部失败返回 null → 调用方报错引导。
7954
7977
  */
7955
7978
  function resolveSessionMdPath(root, scope, session) {
@@ -7957,8 +7980,8 @@ function resolveSessionMdPath(root, scope, session) {
7957
7980
  candidates.push(getActiveSessionInfo(scope)?.mdPath ?? null);
7958
7981
  const events = sessionEvents(session);
7959
7982
  if (events.length > 0) candidates.push(parseSessionContextFromEvents(events)?.mdPath ?? null);
7983
+ candidates.push(readLastBound(session)?.mdPath ?? null);
7960
7984
  candidates.push(parseAnchorMdPath(session));
7961
- candidates.push(findLatestActiveSessionMd(root));
7962
7985
  for (const c of candidates) {
7963
7986
  if (!c) continue;
7964
7987
  const abs = c.startsWith(root) ? c : resolve(root, c);
@@ -8008,7 +8031,7 @@ async function queueRebuild(ctx, opts) {
8008
8031
  const session = ctx.sessions?.get?.(dshSessionId);
8009
8032
  if (!session) throw new Error(`Unable to locate dsh session ${dshSessionId} (session may be closed)`);
8010
8033
  const mdPath = resolveSessionMdPath(root, dshSessionId, session);
8011
- if (!mdPath) throw new Error("Unable to determine the active SESSION.md — no session context found in this conversation. Run \"logbook use <S###> --summary <内容概括 ≤20 字>\" first to activate the trajectory to resume, then retry logbook rebuild.");
8034
+ if (!mdPath) throw new Error("Unable to determine the active SESSION.md for this session — checked (in order): in-memory activation (logbook use in this process), [SESSION CONTEXT] events in this conversation, AGENT_SESSIONS/.bindings.json (authoritative binding), rebuild anchor in the surface. None matched an existing SESSION.md. Run \"logbook use <S###> --summary <内容概括 ≤20 字>\" to bind this conversation to a trajectory, then retry logbook rebuild. (No global fallback is applied on purpose — guessing could resume a different trajectory.)");
8012
8035
  const sessionName = getActiveSessionInfo(dshSessionId)?.sessionId ?? sessionNameFromMdPath(mdPath);
8013
8036
  const focus = note && note.trim() !== "" ? note : void 0;
8014
8037
  const anchor = buildRebuildAnchor(root, sessionName, mdPath, DEFAULT_ANCHOR_MESSAGES, focus);
@@ -9535,7 +9558,9 @@ async function handleIncoming(ctx, root, accountId, cred, msg) {
9535
9558
  }
9536
9559
  const parts = [];
9537
9560
  const activeWorkspace = getActiveSessionInfo(sessionId);
9538
- if (activeWorkspace?.mdPath) parts.push(workspaceTrajectoryLine(activeWorkspace.mdPath));
9561
+ if (activeWorkspace?.mdPath) {
9562
+ if (!ensureWorkspacePromptSection(ref.agent, sessionId)) parts.push(workspaceTrajectoryLine(activeWorkspace.mdPath));
9563
+ }
9539
9564
  const manualOutput = settings.autoReplyWithLastMessage === false;
9540
9565
  if (manualOutput) parts.push(weixinManualOutputLine(root, accountId, fromUserId));
9541
9566
  if (text) parts.push(text);
@@ -9872,7 +9897,7 @@ function matchCcc(input, candidates) {
9872
9897
  }
9873
9898
  /** 组装候选列表(discoverCccs 投影 + `.serenity` 名;动态 import 保持本模块静态依赖轻量) */
9874
9899
  async function collectCandidates(ctx) {
9875
- const { discoverCccs } = await import("./skiff-debug-TONJlpgF.js").then((n) => n.i);
9900
+ const { discoverCccs } = await import("./skiff-debug-DYXDdzu3.js").then((n) => n.i);
9876
9901
  const entries = await discoverCccs(ctx, process.cwd());
9877
9902
  const seen = /* @__PURE__ */ new Set();
9878
9903
  const out = [];
@@ -10145,6 +10170,197 @@ function registerLifecycle(ctx) {
10145
10170
  registerDisposer(ctx, "self-started resources (skiff-debug/acp/weixin)", disposeAll);
10146
10171
  }
10147
10172
  //#endregion
10173
+ //#region src/web-fetch-provider.ts
10174
+ /**
10175
+ * web-fetch-provider.ts — fake-ip / TUN 网络下的 web_fetch provider(v1.30.12,S142)
10176
+ *
10177
+ * 问题(用户报告 + 实证):`web_fetch` 在本机恒失败,报
10178
+ * `URL hostname "X" resolves to a non-public IP address`(`WEB_BLOCKED_URL`)。
10179
+ * 根因不是 DSH 误判,而是**本地 DNS 在说谎**:Clash/mihomo 的 fake-ip 让所有域名解析到
10180
+ * 198.18.0.0/15(实证 `cdn.jsdelivr.net → 198.18.1.85`),而宿主的
10181
+ * `@deepseek-ai/dsh-web-fetch-http` 用 `ipaddr.js` 判定 `range() === "unicast"`,
10182
+ * 该段属 reserved → 无条件 throw(其 Config 只有 5 个配额字段,**没有开关**)。
10183
+ *
10184
+ * 方案(用户拍板 L3):ACC 自己注册一个 fetch provider——**复用宿主的 HttpFetchProvider**
10185
+ * (重定向策略/字节与字符配额/字符集解码/连接固定全部继承),只替换 `resolveAddresses`:
10186
+ * 「必须公网单播」→「公网单播 **或** fake-ip 段」。其余私网段(loopback / link-local /
10187
+ * RFC1918 / CGNAT / 云元数据 169.254.169.254 / ULA / 组播)**依旧拒绝**。
10188
+ *
10189
+ * 屏蔽(用户要求「屏蔽掉 dsh 自身注册的」):宿主内置的 `web-fetch-http` 插件由本包的
10190
+ * `cordis.patch.yml`(bundle patch 层)`disabled: true` 关闭——两者都注册 id `http`
10191
+ * (`LOCAL_FETCH_PROVIDER_ID`,HttpFetchProvider 的实例字段),同时存在会
10192
+ * `WEB_DUPLICATE_PROVIDER`;禁用后由本模块的实例接管同一 id,宿主 `web` 的既有
10193
+ * 配置 `fetchProvider: http` 无需改动(**不改宿主的 web 配置对象**,避免覆盖 searchProvider)。
10194
+ *
10195
+ * 边界:本模块只放宽「地址可达性」一条判据;URL 校验(协议/凭据/长度)、同源重定向、
10196
+ * 配额、二进制拒绝全部仍由宿主实现执行。
10197
+ */
10198
+ /**
10199
+ * 与宿主 `WebError` 同形的最小错误(带机器可路由的 `code`)。
10200
+ *
10201
+ * 为什么不 import 宿主的 WebError:宿主 peer 包在测试环境不可解析(现有测试全部
10202
+ * `vi.mock('@deepseek-ai/dsh-tools')` 同因),而 `dsh-tool-web` 只渲染 `error.message`、
10203
+ * 不判 `instanceof`——因此本地错误对象足以满足契约,且让本模块零运行时宿主依赖。
10204
+ */
10205
+ function fetchError(message, code) {
10206
+ const error = new Error(message);
10207
+ error.code = code;
10208
+ return error;
10209
+ }
10210
+ /**
10211
+ * 传输与配额上限。
10212
+ *
10213
+ * 值镜像 `@deepseek-ai/dsh-web-fetch-http` 的 Config 默认值(该包 README 的字段表;
10214
+ * 它不导出解析后的默认值,`Config` 是 schemastery schema 而非结果)。`userAgent`
10215
+ * 运行时取该包导出的 `DEFAULT_USER_AGENT`(单一真相源,不复制字符串)。
10216
+ */
10217
+ const FETCH_LIMIT_DEFAULTS = {
10218
+ maxResponseBytes: 5e6,
10219
+ maxBodyChars: 1e5,
10220
+ timeoutMs: 3e4,
10221
+ maxRedirects: 5
10222
+ };
10223
+ /** Clash/mihomo fake-ip 默认段 198.18.0.0/15(可配 fake-ip-range;改配置需同步此常量) */
10224
+ const FAKE_IP_OCTET_A = 198;
10225
+ const FAKE_IP_OCTET_B_MIN = 18;
10226
+ const FAKE_IP_OCTET_B_MAX = 19;
10227
+ /** 去掉 IPv6 字面量的方括号(`[::1]` → `::1`) */
10228
+ function stripBrackets(hostname) {
10229
+ return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
10230
+ }
10231
+ function octets(address) {
10232
+ const parts = address.split(".");
10233
+ if (parts.length !== 4) return null;
10234
+ const nums = parts.map((p) => /^\d{1,3}$/.test(p) ? Number(p) : NaN);
10235
+ const [a, b, c, d] = nums;
10236
+ if (a === void 0 || b === void 0 || c === void 0 || d === void 0) return null;
10237
+ if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
10238
+ return [
10239
+ a,
10240
+ b,
10241
+ c,
10242
+ d
10243
+ ];
10244
+ }
10245
+ /** 公网单播 IPv4(拒绝全部保留/私网段——与宿主判定口径一致,只额外放行 fake-ip) */
10246
+ function isPublicV4(address) {
10247
+ const parsed = octets(address);
10248
+ if (parsed === null) return false;
10249
+ const [a, b] = parsed;
10250
+ if (a === 0 || a === 10 || a === 127) return false;
10251
+ if (a === 100 && b >= 64 && b <= 127) return false;
10252
+ if (a === 169 && b === 254) return false;
10253
+ if (a === 172 && b >= 16 && b <= 31) return false;
10254
+ if (a === 192 && b === 168) return false;
10255
+ if (a === 192 && b === 0) return false;
10256
+ if (a === 198 && (b === 18 || b === 19)) return false;
10257
+ if (a === 198 && b === 51) return false;
10258
+ if (a === 203 && b === 0) return false;
10259
+ if (a >= 224) return false;
10260
+ return true;
10261
+ }
10262
+ /** fake-ip 段判定(198.18.0.0/15) */
10263
+ function isFakeIpV4(address) {
10264
+ const parsed = octets(address);
10265
+ if (parsed === null) return false;
10266
+ const [a, b] = parsed;
10267
+ return a === FAKE_IP_OCTET_A && b >= FAKE_IP_OCTET_B_MIN && b <= FAKE_IP_OCTET_B_MAX;
10268
+ }
10269
+ /**
10270
+ * 公网单播 IPv6:只接受全局单播 2000::/3;IPv4-mapped(`::ffff:a.b.c.d`)按内嵌 IPv4 判定。
10271
+ * 其余(`::1` / `::` / ULA fc00::/7 / link-local fe80::/10 / 组播 ff00::/8 / v4-translated)一律拒绝。
10272
+ */
10273
+ function isPublicV6(address) {
10274
+ const lower = address.toLowerCase();
10275
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(lower);
10276
+ if (mapped?.[1] !== void 0) return isPublicV4(mapped[1]);
10277
+ const first = lower.split(":")[0] ?? "";
10278
+ if (first === "") return false;
10279
+ const value = Number.parseInt(first, 16);
10280
+ if (!Number.isInteger(value)) return false;
10281
+ return value >= 8192 && value <= 16383;
10282
+ }
10283
+ /** 该地址是否允许被抓取:公网单播 **或** fake-ip 段 */
10284
+ function isAllowedFetchAddress(address) {
10285
+ const bare = stripBrackets(address);
10286
+ const family = isIP(bare);
10287
+ if (family === 4) return isPublicV4(bare) || isFakeIpV4(bare);
10288
+ if (family === 6) return isPublicV6(bare);
10289
+ return false;
10290
+ }
10291
+ /**
10292
+ * 解析并校验地址集合(宿主 `HttpFetchResolver` 契约)。
10293
+ *
10294
+ * 语义与宿主 `resolvePublicAddresses` 一致——**任一地址不合法即整体拒绝**(防 DNS 重绑定),
10295
+ * 仅判据从「公网单播」放宽为 `isAllowedFetchAddress`。
10296
+ */
10297
+ const resolveAllowedAddresses = async (hostname, signal) => {
10298
+ const bare = stripBrackets(hostname);
10299
+ const literalFamily = isIP(bare);
10300
+ let resolved;
10301
+ if (literalFamily === 0) resolved = await lookupAll(bare, signal);
10302
+ else resolved = [{
10303
+ address: bare,
10304
+ family: literalFamily
10305
+ }];
10306
+ if (resolved.length === 0) throw fetchError(`hostname "${hostname}" resolved to no addresses`, "WEB_PROVIDER_ERROR");
10307
+ const addresses = [];
10308
+ for (const entry of resolved) {
10309
+ if (entry.family !== 4 && entry.family !== 6) throw fetchError(`hostname "${hostname}" resolved to an invalid IP address`, "WEB_PROVIDER_ERROR");
10310
+ if (isIP(entry.address) !== entry.family) throw fetchError(`hostname "${hostname}" resolved to an invalid IP address`, "WEB_PROVIDER_ERROR");
10311
+ if (!isAllowedFetchAddress(entry.address)) throw fetchError(`URL hostname "${hostname}" resolves to a non-public IP address`, "WEB_BLOCKED_URL");
10312
+ addresses.push({
10313
+ address: entry.address,
10314
+ family: entry.family
10315
+ });
10316
+ }
10317
+ return addresses;
10318
+ };
10319
+ /** `dns.lookup` 的 `all` 形态 + 信号中断(宿主同款语义:OS 查询可能无谓完成) */
10320
+ async function lookupAll(hostname, signal) {
10321
+ if (signal.aborted) throw fetchError("web fetch aborted", "WEB_ABORTED");
10322
+ const lookupPromise = lookup(hostname, {
10323
+ all: true,
10324
+ order: "verbatim"
10325
+ });
10326
+ const aborted = new Promise((_, reject) => {
10327
+ signal.addEventListener("abort", () => reject(fetchError("web fetch aborted", "WEB_ABORTED")), { once: true });
10328
+ });
10329
+ try {
10330
+ return (await Promise.race([lookupPromise, aborted])).map((e) => ({
10331
+ address: e.address,
10332
+ family: e.family
10333
+ }));
10334
+ } finally {
10335
+ lookupPromise.catch(() => void 0);
10336
+ }
10337
+ }
10338
+ /**
10339
+ * 注册接管 `http` id 的 fetch provider(宿主内置 provider 由本包 bundle patch 禁用)。
10340
+ *
10341
+ * 后端包 `@deepseek-ai/dsh-web-fetch-http` 用**动态 import**:宿主版本里若没有该包,
10342
+ * 只降级为一条告警(绝不因缺一个可选后端而让整机启动失败——apply 抛错 = dsh 启动失败)。
10343
+ * 失败一律响亮但不抛错。
10344
+ */
10345
+ async function registerWebFetchProvider(ctx) {
10346
+ const web = hostWeb(ctx);
10347
+ if (typeof web?.registerFetchProvider !== "function") {
10348
+ console.warn("[serenity-hooks] ✗ web fetch provider 未注册:宿主 web 服务不可用(web_fetch 退回宿主默认实现)");
10349
+ return;
10350
+ }
10351
+ try {
10352
+ const backend = await import("@deepseek-ai/dsh-web-fetch-http");
10353
+ const limits = {
10354
+ ...FETCH_LIMIT_DEFAULTS,
10355
+ userAgent: backend.DEFAULT_USER_AGENT
10356
+ };
10357
+ web.registerFetchProvider(new backend.HttpFetchProvider(limits, resolveAllowedAddresses));
10358
+ console.log(`[serenity-hooks] ✓ web fetch provider 已接管(id=${backend.LOCAL_FETCH_PROVIDER_ID},额外放行 fake-ip 段 198.18.0.0/15)`);
10359
+ } catch (error) {
10360
+ console.error(`[serenity-hooks] ✗ web fetch provider 注册失败(web_fetch 将不可用): ${String(error?.message ?? error)}`);
10361
+ }
10362
+ }
10363
+ //#endregion
10148
10364
  //#region src/index.ts
10149
10365
  const name = "dsh-serenity-hooks";
10150
10366
  /** 主动调用的服务;其余(agent 事件)随 harness 装配必然存在
@@ -10159,7 +10375,8 @@ const inject = [
10159
10375
  "agents",
10160
10376
  "systemPrompt",
10161
10377
  "sessionProjections",
10162
- "settings"
10378
+ "settings",
10379
+ "web"
10163
10380
  ];
10164
10381
  const Config = z.object({
10165
10382
  serenityConfigPaths: z.array(z.string()).default([...DEFAULT_SERENITY_CONFIG_PATHS]),
@@ -10185,7 +10402,8 @@ const Config = z.object({
10185
10402
  acp: z.object({
10186
10403
  enabled: z.boolean().default(false),
10187
10404
  httpPort: z.number().min(1024).max(65535).default(3100)
10188
- })
10405
+ }),
10406
+ webFetch: z.object({ enabled: z.boolean().default(true) })
10189
10407
  });
10190
10408
  function apply(ctx, config) {
10191
10409
  try {
@@ -10232,38 +10450,95 @@ function apply(ctx, config) {
10232
10450
  registerWeixinBridge(ctx);
10233
10451
  registerWeixinSendApi(ctx);
10234
10452
  registerLifecycle(ctx);
10453
+ if (config.webFetch?.enabled !== false) registerWebFetchProvider(ctx);
10235
10454
  }
10236
10455
  /**
10237
10456
  * F4 Skiff 调试服务装配:启停 = 人工(设置面板 Skiff 区块开关,settings 持久化)。
10238
10457
  * settings-changed 事件触发同步(skiffEnabled 开 → 启动调试服务;关 → 停止)。
10239
10458
  * 角色配置(skiff.roles)从当前 CCC 根读取(进程 cwd 优先,live 会话兜底)。
10459
+ *
10460
+ * v1.30.13(S142 诊断 D1):**CCC root 解析失败要重试**。旧实现只在 apply 时同步一次——
10461
+ * 此刻通常还没有 live 会话,进程 cwd(服务启动目录)也不在 CCC 内 → `resolveSkiffRoot`
10462
+ * 返回 null → 打印一行警告后**永不重试**(实证:重启日志
10463
+ * `✗ Skiff 调试服务未启动:无法定位 CCC root`,`ss -ltn` 无 3099;同批 ACP 因容忍
10464
+ * `root ?? undefined` 而正常启动)。现形态三层触发(用户"锚定要准确"同源要求):
10465
+ * ① 启动时同步一次 ② 定位失败 → 定时退避重试(1s/3s/8s/20s/40s,共 5 次)
10466
+ * ③ 首个 live 会话就绪(`agent/session-start` / `session/created`)→ 立即再试一次。
10240
10467
  */
10241
10468
  function registerSkiff(ctx) {
10242
10469
  let started = false;
10243
- const sync = () => {
10470
+ let retryTimer = null;
10471
+ let retries = 0;
10472
+ let warnedRoot = false;
10473
+ const clearRetry = () => {
10474
+ if (retryTimer !== null) {
10475
+ clearTimeout(retryTimer);
10476
+ retryTimer = null;
10477
+ }
10478
+ };
10479
+ /** 退避重试定位 CCC root(幂等:已有待执行重试则不再排) */
10480
+ const scheduleRootRetry = () => {
10481
+ if (retryTimer !== null || started) return;
10482
+ if (retries >= SKIFF_ROOT_RETRY_DELAYS_MS.length) {
10483
+ console.warn(`[serenity-hooks] ✗ Skiff 调试服务未启动:重试 ${retries} 次仍无法定位 CCC root(进程 cwd 与 live 会话均无 .serenity)`);
10484
+ return;
10485
+ }
10486
+ const delay = SKIFF_ROOT_RETRY_DELAYS_MS[retries] ?? 0;
10487
+ retryTimer = setTimeout(() => {
10488
+ retryTimer = null;
10489
+ retries += 1;
10490
+ sync();
10491
+ }, delay);
10492
+ retryTimer.unref?.();
10493
+ };
10494
+ function sync() {
10244
10495
  const s = readSimpleSettings();
10245
10496
  if (s.skiffEnabled && !started) {
10246
10497
  const root = resolveSkiffRoot(ctx);
10247
10498
  if (!root) {
10248
- console.warn("[serenity-hooks] ✗ Skiff 调试服务未启动:无法定位 CCC root(进程 cwd 与 live 会话均无 .serenity)");
10499
+ if (!warnedRoot) {
10500
+ warnedRoot = true;
10501
+ console.warn("[serenity-hooks] ✗ Skiff 调试服务未启动:无法定位 CCC root(进程 cwd 与 live 会话均无 .serenity)——已排入退避重试");
10502
+ }
10503
+ scheduleRootRetry();
10249
10504
  return;
10250
10505
  }
10506
+ clearRetry();
10251
10507
  const webPort = readWebPort(ctx);
10252
10508
  startSkiffDebugServer(ctx, root, s.skiffDebugPort, webPort).then(() => {
10253
10509
  started = true;
10510
+ if (retries > 0) console.info(`[serenity-hooks] ✓ Skiff 调试服务已启动(重试 ${retries} 次后定位到 CCC root: ${root})`);
10254
10511
  }).catch((err) => {
10255
10512
  console.error(`[serenity-hooks] ✗ Skiff 调试服务启动失败: ${String(err?.message ?? err)}`);
10256
10513
  });
10257
10514
  } else if (!s.skiffEnabled && started) {
10258
10515
  stopSkiffDebugServer();
10259
10516
  started = false;
10517
+ } else if (!s.skiffEnabled) {
10518
+ clearRetry();
10519
+ retries = 0;
10520
+ warnedRoot = false;
10260
10521
  }
10261
- };
10522
+ }
10262
10523
  try {
10263
10524
  ctx.on("serenity/settings-changed", sync);
10264
10525
  } catch {}
10526
+ for (const eventName of ["agent/session-start", "session/created"]) try {
10527
+ ctx.on(eventName, () => {
10528
+ if (!started) sync();
10529
+ });
10530
+ } catch {}
10265
10531
  sync();
10266
- }
10532
+ registerDisposer(ctx, "skiff root retry timer", clearRetry);
10533
+ }
10534
+ /** Skiff 调试服务 CCC root 退避重试间隔(毫秒;累计 ~72s 后放弃并响亮告警) */
10535
+ const SKIFF_ROOT_RETRY_DELAYS_MS = [
10536
+ 1e3,
10537
+ 3e3,
10538
+ 8e3,
10539
+ 2e4,
10540
+ 4e4
10541
+ ];
10267
10542
  /**
10268
10543
  * 解析 Skiff 调试服务绑定的 CCC 根(v1.25.2 用户指出:skiff 必须绑定 CCC):
10269
10544
  * ① live 会话中**配置了 skiff.roles 的 CCC 优先**(用户认知中的绑定目标)
package/lib/rebuild.d.ts CHANGED
@@ -71,7 +71,17 @@ export interface RebuildResult {
71
71
  /**
72
72
  * 稳固的 SESSION.md 定位(多层候选 + 存在性校验,**绝不输出虚假路径**):
73
73
  * ① 内存活跃会话(本会话显式 use 过)→ ② events 恢复(use 标记 + 重建锚点规范行,进程重启后)
74
- * → ③ surface 首条锚点(events 异常时兜底)→ ④ AGENT_SESSIONS 约定回退(最新未完成活动目录)。
74
+ * → ②b **权威绑定**(`AGENT_SESSIONS/.bindings.json`,本会话持久绑定)→ ③ surface 首条锚点
75
+ * (events 异常时兜底)。
76
+ *
77
+ * v1.30.13(S142 用户"微信桥 + skiff 会话锚定要准确",诊断 D4):**移除**原 ④「全局最新未完成
78
+ * 会话」(`findLatestActiveSessionMd`)。理由(R↓):④ 是**跨轨迹**猜测——候选①②③全空时,
79
+ * 它会把 rebuild 静默接到 AGENT_SESSIONS 里最新的另一个会话(临时 skiff 会话 persistent=false、
80
+ * 重启后首条消息前、Danica 新会话都命中该形态),而"接错轨迹"比重建失败危险得多
81
+ * (错误轨迹被继续写入 + 正确轨迹静默丢失)。现在全部候选失败 → 返回 null → 调用方
82
+ * 报错引导用户显式 `logbook use`(响亮失败优于静默接错)。
83
+ * 备选:④ 仅在"同 scope 确无绑定"时兜底——同样会接错轨迹,故不采纳。
84
+ *
75
85
  * 每候选 resolve 后 existsSync 校验(相对路径按 root 解析);全部失败返回 null → 调用方报错引导。
76
86
  */
77
87
  export declare function resolveSessionMdPath(root: string, scope: string, session: Session): string | null;
@@ -412,19 +412,6 @@ function resolveSessionByTitle(title, sessionsDir) {
412
412
  if (fuzzy.length === 1) return join(fuzzy[0].path, SESSION_MD);
413
413
  return null;
414
414
  }
415
- /**
416
- * 约定回退(v1.24.11):AGENT_SESSIONS 下最新修改的**未完成**会话的 SESSION.md。
417
- * readAllSessions 已按「未完成优先 + mtime 降序」排序 → 首个未完成且含 SESSION.md 即最新活动。
418
- * 只作最后手段(内存/events/锚点全缺时),保证重建锚点至少指向一个真实存在的轨迹。
419
- */
420
- function findLatestActiveSessionMd(root) {
421
- for (const s of readAllSessions(sessionsRoot(root))) {
422
- if (s.status.completed) continue;
423
- const md = join(s.path, SESSION_MD);
424
- if (existsSync(md)) return md;
425
- }
426
- return null;
427
- }
428
415
  /** health 子命令(对齐 osp healthCheck:stale/stalled/ghost/drift 四类检查,文本输出) */
429
416
  function healthCheck(root) {
430
417
  const sessions = readAllSessions(sessionsRoot(root));
@@ -776,4 +763,4 @@ function appendBound(session, action, rec) {
776
763
  }
777
764
  }
778
765
  //#endregion
779
- export { summarize as C, showSession as S, readActiveSessionMd as _, archiveSessions as a, sessionsRoot as b, createSession as c, findSession as d, getActiveSessionInfo as f, qaCheck as g, parseSessionContextFromEvents as h, SESSION_ACTIONS as i, extractSessionMdPathFromText as l, listSessions as m, readLastBound as n, clearActiveSessionInfo as o, healthCheck as p, DEFAULT_SESSION_SCOPE as r, closeSession as s, appendBound as t, findLatestActiveSessionMd as u, resolveSessionByTitle as v, useSession as w, setActiveSessionInfo as x, sessionEvents as y };
766
+ export { useSession as C, summarize as S, resolveSessionByTitle as _, archiveSessions as a, setActiveSessionInfo as b, createSession as c, getActiveSessionInfo as d, healthCheck as f, readActiveSessionMd as g, qaCheck as h, SESSION_ACTIONS as i, extractSessionMdPathFromText as l, parseSessionContextFromEvents as m, readLastBound as n, clearActiveSessionInfo as o, listSessions as p, DEFAULT_SESSION_SCOPE as r, closeSession as s, appendBound as t, findSession as u, sessionEvents as v, showSession as x, sessionsRoot as y };
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
- import { a as hostSettings } from "./access-CdL6BAYj.js";
2
+ import { a as hostSettings } from "./access-dU_vG1q8.js";
3
3
  import z from "@deepseek-ai/schemastery";
4
4
  //#region src/settings-section.ts
5
5
  var settings_section_exports = /* @__PURE__ */ __exportAll({
@@ -31,10 +31,36 @@ export declare function skiffSessionSnapshot(): ReadonlyMap<string, {
31
31
  * 系统提示词中的**工作台纪律块**(v1.30.4 升级:从单行路径 → 完整 ACC 层约束)。
32
32
  * 用户点破(S142):只给路径 LLM 不会主动用——需注入「已绑定 + 使用纪律 + 动作指引」。
33
33
  * SESSION = 工作台——skiff 与主舱同一套机制(零特调,用户拍板):
34
- * 自动绑定已生效(无需 logbook use)、SESSION.md 是持久记忆载体(write/edit 维护)、
35
- * rebuild 自动从本 SESSION 续接。注入 agent 系统提示词(用户对话面不可见)。
34
+ * 自动绑定已生效(无需 logbook use)、SESSION.md 是持久记忆载体、rebuild 自动从本 SESSION 续接。
35
+ * 注入 agent 系统提示词(用户对话面不可见)。
36
+ *
37
+ * v1.30.13(S142 诊断 D5):写入纪律**不点名具体工具**。旧文案写死 "with write/edit",
38
+ * 而 zhaocai 角色的工具白名单已移除 write/edit(写能力收归 CCC 的 session-write MSM)
39
+ * → 指令指向它根本没有的工具(注入纪律与能力面矛盾,模型只能猜)。
40
+ * 现文案改为「用本角色被授权的写入通道」——ACC 只声明义务(写进工作台),
41
+ * 具体通道由 CCC 的角色配置与角色提示词决定(ACC/CCC 归属二分;也不把某个 CCC 的
42
+ * MSM 名硬编码进 ACC 源码)。
36
43
  */
37
44
  export declare function workspaceTrajectoryLine(mdPath: string): string;
45
+ /**
46
+ * 把**工作台纪律块**挂到 agent 的系统提示词段(单一真相源,v1.30.13 用户需求)。
47
+ *
48
+ * 用户原话(S142,2026-09-08):"微信桥的注入机制,每个用户消息都会注入,skiff 本身也会注入,
49
+ * 这样就重复,能否微信桥情况下,注入内容直接取 skiff 的,这样不用配两遍"。
50
+ *
51
+ * 现状(改前):`createSkiffAgent` 把 `workspaceTrajectoryLine(mdPath)` 塞进基础段
52
+ * (创建时一次快照),微信桥又**每条消息**把同一文本拼进 question(v1.30.4 为覆盖
53
+ * "live/existing 快路径不重挂提示词"而加)→ 新建 agent 首轮起即重复,且每轮重复付 token。
54
+ *
55
+ * 现形态(单一注入点):工作台行**只**经本函数挂系统提示词段(name `serenity-skiff-workspace`,
56
+ * 动态 `text()` 每轮按 scope 读当前活跃 mdPath → 绑定变化自动跟随,无需重挂):
57
+ * - `createSkiffAgent` 调它(不再把工作台行拼进基础段);
58
+ * - 微信桥对 live/existing agent 调它(不再拼 question)。
59
+ *
60
+ * @returns true = 段已在位(本次注册成功或此前已注册);false = 该 agent 无法挂系统提示词段
61
+ * (宿主 systemPrompt 缺失/注册抛错)→ 调用方降级为 question 前缀注入(约束必须在场)
62
+ */
63
+ export declare function ensureWorkspacePromptSection(agent: Agent, scope: string): boolean;
38
64
  /**
39
65
  * 确保启用 session 能力的 skiff 会话拥有**专属 SESSION**(工作台):
40
66
  * 懒绑定 + 幂等(per skiff 会话 scope 隔离——微信多用户各自独立,永不共享同一份)。
@@ -1,8 +1,8 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
2
  import { a as findSerenityRoot, f as readHandymanConfig } from "./ccc-DAsSHsub.js";
3
3
  import { a as resolveRoleSystemPrompt, i as readSkiffRoles, n as buildSkiffBasePrompt, o as roleMsmWhitelist, r as isSkiffSessionId, u as trajectorySubset } from "./skiff-role-DlrbHPLD.js";
4
- import { i as hostSessions, r as hostService, t as hostAgents } from "./access-CdL6BAYj.js";
5
- import { c as createSession, f as getActiveSessionInfo, n as readLastBound, t as appendBound, x as setActiveSessionInfo } from "./session-bound-D2ANqVn-.js";
4
+ import { i as hostSessions, r as hostService, t as hostAgents } from "./access-dU_vG1q8.js";
5
+ import { b as setActiveSessionInfo, c as createSession, d as getActiveSessionInfo, n as readLastBound, t as appendBound } from "./session-bound-BpTayaIS.js";
6
6
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
7
7
  import { basename, join } from "node:path";
8
8
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
@@ -293,8 +293,15 @@ const AUTO_BOUND_NOTE_PREFIX = "auto-created for skiff role";
293
293
  * 系统提示词中的**工作台纪律块**(v1.30.4 升级:从单行路径 → 完整 ACC 层约束)。
294
294
  * 用户点破(S142):只给路径 LLM 不会主动用——需注入「已绑定 + 使用纪律 + 动作指引」。
295
295
  * SESSION = 工作台——skiff 与主舱同一套机制(零特调,用户拍板):
296
- * 自动绑定已生效(无需 logbook use)、SESSION.md 是持久记忆载体(write/edit 维护)、
297
- * rebuild 自动从本 SESSION 续接。注入 agent 系统提示词(用户对话面不可见)。
296
+ * 自动绑定已生效(无需 logbook use)、SESSION.md 是持久记忆载体、rebuild 自动从本 SESSION 续接。
297
+ * 注入 agent 系统提示词(用户对话面不可见)。
298
+ *
299
+ * v1.30.13(S142 诊断 D5):写入纪律**不点名具体工具**。旧文案写死 "with write/edit",
300
+ * 而 zhaocai 角色的工具白名单已移除 write/edit(写能力收归 CCC 的 session-write MSM)
301
+ * → 指令指向它根本没有的工具(注入纪律与能力面矛盾,模型只能猜)。
302
+ * 现文案改为「用本角色被授权的写入通道」——ACC 只声明义务(写进工作台),
303
+ * 具体通道由 CCC 的角色配置与角色提示词决定(ACC/CCC 归属二分;也不把某个 CCC 的
304
+ * MSM 名硬编码进 ACC 源码)。
298
305
  */
299
306
  function workspaceTrajectoryLine(mdPath) {
300
307
  return [
@@ -305,8 +312,10 @@ function workspaceTrajectoryLine(mdPath) {
305
312
  "Rules of the workspace:",
306
313
  " 1. This SESSION.md is your persistent memory carrier (like the main cabin SESSION) — ",
307
314
  " read it first when context feels thin or work spans turns.",
308
- " 2. Record key decisions/progress/unresolved items INTO this SESSION.md with write/edit",
309
- " (sections: 目标 / 状态 / 关键决策 / 进度记录 / 未解决的问题). Keep it current — it is",
315
+ " 2. Record key decisions/progress/unresolved items INTO this SESSION.md using the write channel",
316
+ " your role is actually granted (use whatever write tool or MSM this role has — never assume",
317
+ " a specific write tool exists; if none is granted, report that instead of pretending to write).",
318
+ " Sections: 目标 / 状态 / 关键决策 / 进度记录 / 未解决的问题. Keep it current — it is",
310
319
  " what a rebuild resumes from.",
311
320
  " 3. When context pressure is high, run logbook rebuild — it auto-resumes from THIS SESSION.md",
312
321
  " (no manual use; do not switch to or read other SESSIONs unless the user explicitly asks).",
@@ -315,6 +324,50 @@ function workspaceTrajectoryLine(mdPath) {
315
324
  ].join("\n");
316
325
  }
317
326
  /**
327
+ * 已挂工作台提示词段的 agent(幂等:同一 agent 只注册一次,避免 duplicate section)。
328
+ * WeakSet → agent 被回收即自动清理,无泄漏。
329
+ */
330
+ const workspaceSectionAgents = /* @__PURE__ */ new WeakSet();
331
+ /**
332
+ * 把**工作台纪律块**挂到 agent 的系统提示词段(单一真相源,v1.30.13 用户需求)。
333
+ *
334
+ * 用户原话(S142,2026-09-08):"微信桥的注入机制,每个用户消息都会注入,skiff 本身也会注入,
335
+ * 这样就重复,能否微信桥情况下,注入内容直接取 skiff 的,这样不用配两遍"。
336
+ *
337
+ * 现状(改前):`createSkiffAgent` 把 `workspaceTrajectoryLine(mdPath)` 塞进基础段
338
+ * (创建时一次快照),微信桥又**每条消息**把同一文本拼进 question(v1.30.4 为覆盖
339
+ * "live/existing 快路径不重挂提示词"而加)→ 新建 agent 首轮起即重复,且每轮重复付 token。
340
+ *
341
+ * 现形态(单一注入点):工作台行**只**经本函数挂系统提示词段(name `serenity-skiff-workspace`,
342
+ * 动态 `text()` 每轮按 scope 读当前活跃 mdPath → 绑定变化自动跟随,无需重挂):
343
+ * - `createSkiffAgent` 调它(不再把工作台行拼进基础段);
344
+ * - 微信桥对 live/existing agent 调它(不再拼 question)。
345
+ *
346
+ * @returns true = 段已在位(本次注册成功或此前已注册);false = 该 agent 无法挂系统提示词段
347
+ * (宿主 systemPrompt 缺失/注册抛错)→ 调用方降级为 question 前缀注入(约束必须在场)
348
+ */
349
+ function ensureWorkspacePromptSection(agent, scope) {
350
+ if (!agent || typeof agent !== "object") return false;
351
+ if (workspaceSectionAgents.has(agent)) return true;
352
+ const api = agent.ctx?.systemPrompt;
353
+ if (typeof api?.section !== "function") return false;
354
+ try {
355
+ api.section({
356
+ name: "serenity-skiff-workspace",
357
+ order: -55,
358
+ text: () => {
359
+ const mdPath = getActiveSessionInfo(scope)?.mdPath;
360
+ return mdPath ? workspaceTrajectoryLine(mdPath) : "";
361
+ }
362
+ });
363
+ workspaceSectionAgents.add(agent);
364
+ return true;
365
+ } catch (err) {
366
+ console.warn(`[serenity-hooks] skiff 工作台提示词段注册失败(回退 question 注入): ${String(err?.message ?? err)}`);
367
+ return false;
368
+ }
369
+ }
370
+ /**
318
371
  * 确保启用 session 能力的 skiff 会话拥有**专属 SESSION**(工作台):
319
372
  * 懒绑定 + 幂等(per skiff 会话 scope 隔离——微信多用户各自独立,永不共享同一份)。
320
373
  *
@@ -421,15 +474,12 @@ async function createSkiffAgent(ctx, root, roleName, role, defaultModel, session
421
474
  agent.ctx.systemPrompt.section({
422
475
  name: "serenity-skiff",
423
476
  order: -60,
424
- text: () => [
425
- buildSkiffBasePrompt(roleName, role),
426
- workspaceMdPath ? workspaceTrajectoryLine(workspaceMdPath) : "",
427
- cccPrompt
428
- ].filter(Boolean).join("\n")
477
+ text: () => [buildSkiffBasePrompt(roleName, role), cccPrompt].filter(Boolean).join("\n")
429
478
  });
430
479
  } catch (err) {
431
480
  console.warn(`[serenity-hooks] skiff 系统提示词注册失败: ${String(err?.message ?? err)}`);
432
481
  }
482
+ if (workspaceMdPath) ensureWorkspacePromptSection(agent, id);
433
483
  registerSkiffSession(id, roleName, root, agent);
434
484
  return {
435
485
  handle,
@@ -2793,4 +2843,4 @@ async function handle(ctx, defaultRoot, webPort, req, res) {
2793
2843
  }
2794
2844
  }
2795
2845
  //#endregion
2796
- export { newStopToken as C, writeFailedStatus as D, splitModel as E, writeProgress as O, listActiveHandymen as S, requireWhitelistedModel as T, workspaceTrajectoryLine as _, startSkiffDebugServer as a, buildRoundPrompt as b, askSkiff as c, getSkiffAgent as d, skiffMsmGate as f, unregisterSkiffSession as g, skiffTrajectoryEnabled as h, skiff_debug_exports as i, skiffRoleFor$1 as k, createSkiffAgent as l, skiffSessionSnapshot as m, jscSafeJsonText as n, stopSkiffDebugServer as o, skiffSessionInfo as p, renderSkiffMarkdown as r, stripThink as s, discoverCccs as t, ensureSkiffSession as u, waitAgentIdle as v, readProgress as w, handymanProgressPaths as x, HANDYMAN_GUIDE as y };
2846
+ export { skiffRoleFor$1 as A, listActiveHandymen as C, splitModel as D, requireWhitelistedModel as E, writeFailedStatus as O, handymanProgressPaths as S, readProgress as T, unregisterSkiffSession as _, startSkiffDebugServer as a, HANDYMAN_GUIDE as b, askSkiff as c, ensureWorkspacePromptSection as d, getSkiffAgent as f, skiffTrajectoryEnabled as g, skiffSessionSnapshot as h, skiff_debug_exports as i, writeProgress as k, createSkiffAgent as l, skiffSessionInfo as m, jscSafeJsonText as n, stopSkiffDebugServer as o, skiffMsmGate as p, renderSkiffMarkdown as r, stripThink as s, discoverCccs as t, ensureSkiffSession as u, workspaceTrajectoryLine as v, newStopToken as w, buildRoundPrompt as x, waitAgentIdle as y };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * web-fetch-provider.ts — fake-ip / TUN 网络下的 web_fetch provider(v1.30.12,S142)
3
+ *
4
+ * 问题(用户报告 + 实证):`web_fetch` 在本机恒失败,报
5
+ * `URL hostname "X" resolves to a non-public IP address`(`WEB_BLOCKED_URL`)。
6
+ * 根因不是 DSH 误判,而是**本地 DNS 在说谎**:Clash/mihomo 的 fake-ip 让所有域名解析到
7
+ * 198.18.0.0/15(实证 `cdn.jsdelivr.net → 198.18.1.85`),而宿主的
8
+ * `@deepseek-ai/dsh-web-fetch-http` 用 `ipaddr.js` 判定 `range() === "unicast"`,
9
+ * 该段属 reserved → 无条件 throw(其 Config 只有 5 个配额字段,**没有开关**)。
10
+ *
11
+ * 方案(用户拍板 L3):ACC 自己注册一个 fetch provider——**复用宿主的 HttpFetchProvider**
12
+ * (重定向策略/字节与字符配额/字符集解码/连接固定全部继承),只替换 `resolveAddresses`:
13
+ * 「必须公网单播」→「公网单播 **或** fake-ip 段」。其余私网段(loopback / link-local /
14
+ * RFC1918 / CGNAT / 云元数据 169.254.169.254 / ULA / 组播)**依旧拒绝**。
15
+ *
16
+ * 屏蔽(用户要求「屏蔽掉 dsh 自身注册的」):宿主内置的 `web-fetch-http` 插件由本包的
17
+ * `cordis.patch.yml`(bundle patch 层)`disabled: true` 关闭——两者都注册 id `http`
18
+ * (`LOCAL_FETCH_PROVIDER_ID`,HttpFetchProvider 的实例字段),同时存在会
19
+ * `WEB_DUPLICATE_PROVIDER`;禁用后由本模块的实例接管同一 id,宿主 `web` 的既有
20
+ * 配置 `fetchProvider: http` 无需改动(**不改宿主的 web 配置对象**,避免覆盖 searchProvider)。
21
+ *
22
+ * 边界:本模块只放宽「地址可达性」一条判据;URL 校验(协议/凭据/长度)、同源重定向、
23
+ * 配额、二进制拒绝全部仍由宿主实现执行。
24
+ */
25
+ import type { Context } from 'cordis';
26
+ import type { HttpFetchResolver } from '@deepseek-ai/dsh-web-fetch-http';
27
+ /**
28
+ * 与宿主 `WebError` 同形的最小错误(带机器可路由的 `code`)。
29
+ *
30
+ * 为什么不 import 宿主的 WebError:宿主 peer 包在测试环境不可解析(现有测试全部
31
+ * `vi.mock('@deepseek-ai/dsh-tools')` 同因),而 `dsh-tool-web` 只渲染 `error.message`、
32
+ * 不判 `instanceof`——因此本地错误对象足以满足契约,且让本模块零运行时宿主依赖。
33
+ */
34
+ export declare function fetchError(message: string, code: string): Error;
35
+ /**
36
+ * 传输与配额上限。
37
+ *
38
+ * 值镜像 `@deepseek-ai/dsh-web-fetch-http` 的 Config 默认值(该包 README 的字段表;
39
+ * 它不导出解析后的默认值,`Config` 是 schemastery schema 而非结果)。`userAgent`
40
+ * 运行时取该包导出的 `DEFAULT_USER_AGENT`(单一真相源,不复制字符串)。
41
+ */
42
+ export declare const FETCH_LIMIT_DEFAULTS: {
43
+ readonly maxResponseBytes: 5000000;
44
+ readonly maxBodyChars: 100000;
45
+ readonly timeoutMs: 30000;
46
+ readonly maxRedirects: 5;
47
+ };
48
+ /** 公网单播 IPv4(拒绝全部保留/私网段——与宿主判定口径一致,只额外放行 fake-ip) */
49
+ export declare function isPublicV4(address: string): boolean;
50
+ /** fake-ip 段判定(198.18.0.0/15) */
51
+ export declare function isFakeIpV4(address: string): boolean;
52
+ /**
53
+ * 公网单播 IPv6:只接受全局单播 2000::/3;IPv4-mapped(`::ffff:a.b.c.d`)按内嵌 IPv4 判定。
54
+ * 其余(`::1` / `::` / ULA fc00::/7 / link-local fe80::/10 / 组播 ff00::/8 / v4-translated)一律拒绝。
55
+ */
56
+ export declare function isPublicV6(address: string): boolean;
57
+ /** 该地址是否允许被抓取:公网单播 **或** fake-ip 段 */
58
+ export declare function isAllowedFetchAddress(address: string): boolean;
59
+ /**
60
+ * 解析并校验地址集合(宿主 `HttpFetchResolver` 契约)。
61
+ *
62
+ * 语义与宿主 `resolvePublicAddresses` 一致——**任一地址不合法即整体拒绝**(防 DNS 重绑定),
63
+ * 仅判据从「公网单播」放宽为 `isAllowedFetchAddress`。
64
+ */
65
+ export declare const resolveAllowedAddresses: HttpFetchResolver;
66
+ /**
67
+ * 注册接管 `http` id 的 fetch provider(宿主内置 provider 由本包 bundle patch 禁用)。
68
+ *
69
+ * 后端包 `@deepseek-ai/dsh-web-fetch-http` 用**动态 import**:宿主版本里若没有该包,
70
+ * 只降级为一条告警(绝不因缺一个可选后端而让整机启动失败——apply 抛错 = dsh 启动失败)。
71
+ * 失败一律响亮但不抛错。
72
+ */
73
+ export declare function registerWebFetchProvider(ctx: Context): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.30.11",
3
+ "version": "1.30.13",
4
4
  "description": "宁静号 ACC harness(Native Cordis 插件)——给 DeepSeek Harness 装一个「AI 工作区」:10 个工具(container_fs/logbook/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/autopilot-trajectory)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 工作日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/自主巡航。适配 DSH 0.1.2-rc.1。",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -65,6 +65,8 @@
65
65
  "@deepseek-ai/dsh-skill": "^0.1.2-rc.1",
66
66
  "@deepseek-ai/dsh-system-prompt": "^0.1.2-rc.1",
67
67
  "@deepseek-ai/dsh-tools": "^0.1.2-rc.1",
68
+ "@deepseek-ai/dsh-web": "^0.1.2-rc.1",
69
+ "@deepseek-ai/dsh-web-fetch-http": "^0.1.2-rc.1",
68
70
  "@deepseek-ai/schemastery": "^3.18.1",
69
71
  "cordis": "^4.0.0-rc.7",
70
72
  "@deepseek-ai/cordis": "^4.0.0-rc.7"
@@ -87,6 +89,9 @@
87
89
  },
88
90
  "@deepseek-ai/dsh-compaction": {
89
91
  "optional": true
92
+ },
93
+ "@deepseek-ai/dsh-web-fetch-http": {
94
+ "optional": true
90
95
  }
91
96
  },
92
97
  "devDependencies": {