dsh-tiddlywiki 0.28.13 → 0.29.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.
@@ -73,8 +73,16 @@ export interface SessionRouteDeps {
73
73
  * renders all three. `set` persists a choice, or clears it with `undefined`.
74
74
  */
75
75
  export interface SessionScopeFace {
76
- /** Current choice + what it resolves to (and why, when it cannot serve). */
77
- get: (sessionId: string) => { scope?: string; resolved?: { id: string; label: string }; reason?: string }
76
+ /**
77
+ * Current choice + what it resolves to (and why, when it cannot serve).
78
+ *
79
+ * `mode` is the registry mode (v0.29.0): without it a client cannot tell
80
+ * "`resolved` is the answer because there is only one wiki" (single install →
81
+ * keep the old DOM) from "`resolved` is the DEFAULT wiki this session's tools
82
+ * actually act on" (multi → its links must open THAT wiki, not whichever one
83
+ * the GUI happens to be focused on).
84
+ */
85
+ get: (sessionId: string) => { scope?: string; resolved?: { id: string; label: string }; reason?: string; mode?: string }
78
86
  /** Point the session at a wiki, or clear the choice with `undefined`. */
79
87
  set: (sessionId: string, wikiId: string | undefined) => Promise<void>
80
88
  }
@@ -195,6 +195,17 @@ export interface RouteDeps {
195
195
  uiDefaults: (req: IncomingMessage) => UiDefaultsPublic
196
196
  /** Absolute folder of the targeted wiki. */
197
197
  getWikiPath: (req: IncomingMessage) => string
198
+ /**
199
+ * Why this request names a knowledge base that cannot serve it (v0.29.0).
200
+ *
201
+ * Returns an actionable sentence when `?wiki=<id>` names a REGISTERED but
202
+ * STOPPED wiki, `undefined` otherwise (no selector / running / unknown id).
203
+ * Routes that already 503 on a missing client use it to NAME the wiki; the
204
+ * ones whose accessors have a harmless-looking fallback (config, wiki path,
205
+ * seed status) MUST check it first, or they answer with ANOTHER wiki's data —
206
+ * the silent-wrong-wiki class this hook exists to close.
207
+ */
208
+ targetProblem?: (req: IncomingMessage) => string | undefined
198
209
  /**
199
210
  * After a pull moved HEAD: restart the running wikis whose content actually
200
211
  * changed, and report their ids (v0.28.0). `changedFiles` are
@@ -572,8 +583,33 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
572
583
  */
573
584
  const twProxyAbsoluteBase = (req: IncomingMessage): string | undefined => absoluteHostBase(req.headers.host, TW_PROXY_PATH)
574
585
 
586
+ /**
587
+ * 503 when `?wiki=<id>` names a REGISTERED but STOPPED knowledge base (v0.29.0).
588
+ *
589
+ * Before this, such a request fell through to the DEFAULT wiki — the settings
590
+ * page said 「正在配置:books」 and then synced/restarted/uploaded into the work
591
+ * wiki, silently. `deps.targetProblem` is the single place that knows the
592
+ * difference between "unknown id" (still falls back: stale bookmark) and
593
+ * "known but not running" (must refuse), so every route that can act asks it.
594
+ */
595
+ const refuseStoppedTarget = (req: IncomingMessage, res: ServerResponse): boolean => {
596
+ const problem = deps.targetProblem?.(req)
597
+ if (problem === undefined) return false
598
+ json(res, { ok: false, error: problem }, 503)
599
+ return true
600
+ }
601
+
602
+ /**
603
+ * The 503 body for "no running wiki behind this request": names the wiki when
604
+ * the request asked for one that is stopped, otherwise the historical text.
605
+ */
606
+ const notRunning = (req: IncomingMessage): string => deps.targetProblem?.(req) ?? 'wiki service is not running'
607
+
575
608
  const handleStatus = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
576
609
  if (rejectNonRead(req, res)) return
610
+ // A `?wiki=` that names a stopped wiki must not answer with ANOTHER wiki's
611
+ // path/git/note defaults (the settings page's 状态 row reads exactly this).
612
+ if (refuseStoppedTarget(req, res)) return
577
613
  const view = deps.server(req)?.status() ?? { status: 'stopped' as const, wikiPath: deps.getWikiPath(req), logs: [] }
578
614
  const gitSummary = await cachedGitStatus(req)
579
615
  json(res, {
@@ -656,7 +692,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
656
692
  }
657
693
  const client = deps.getClient(req)
658
694
  if (client === undefined) {
659
- json(res, { ok: false, error: 'wiki service is not running' }, 503)
695
+ json(res, { ok: false, error: notRunning(req) }, 503)
660
696
  return
661
697
  }
662
698
  const title = typeof body.title === 'string' && body.title.trim().length > 0 ? body.title.trim() : timestampTitle()
@@ -700,7 +736,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
700
736
  const body = JSON.parse(await readBody(req)) as { title?: unknown; tag?: unknown; tags?: unknown; text?: unknown; expectedModified?: unknown; expectedRevision?: unknown; force?: unknown }
701
737
  const client = deps.getClient(req)
702
738
  if (client === undefined) {
703
- json(res, { ok: false, error: 'wiki service is not running' }, 503)
739
+ json(res, { ok: false, error: notRunning(req) }, 503)
704
740
  return
705
741
  }
706
742
  const title = typeof body.title === 'string' && body.title.trim().length > 0 ? body.title.trim() : timestampTitle()
@@ -738,7 +774,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
738
774
  if (rejectNonRead(req, res)) return
739
775
  const client = deps.getClient(req)
740
776
  if (client === undefined) {
741
- json(res, { ok: false, error: 'wiki service is not running' }, 503)
777
+ json(res, { ok: false, error: notRunning(req) }, 503)
742
778
  return
743
779
  }
744
780
  try {
@@ -767,7 +803,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
767
803
  if (rejectNonRead(req, res)) return
768
804
  const client = deps.getClient(req)
769
805
  if (client === undefined) {
770
- json(res, { ok: false, error: 'wiki service is not running' }, 503)
806
+ json(res, { ok: false, error: notRunning(req) }, 503)
771
807
  return
772
808
  }
773
809
  try {
@@ -800,7 +836,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
800
836
  if (rejectNonRead(req, res)) return
801
837
  const client = deps.getClient(req)
802
838
  if (client === undefined) {
803
- json(res, { ok: false, error: 'wiki service is not running' }, 503)
839
+ json(res, { ok: false, error: notRunning(req) }, 503)
804
840
  return
805
841
  }
806
842
  try {
@@ -861,7 +897,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
861
897
  if (rejectNonRead(req, res)) return
862
898
  const client = deps.getClient(req)
863
899
  if (client === undefined) {
864
- json(res, { ok: false, error: 'wiki service is not running' }, 503)
900
+ json(res, { ok: false, error: notRunning(req) }, 503)
865
901
  return
866
902
  }
867
903
  try {
@@ -923,6 +959,9 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
923
959
  const handleRestart = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
924
960
  try {
925
961
  if (rejectCrossSiteWrite(req, res, ['POST'])) return
962
+ // Sync is a repository/folder operation on the target wiki's path: without
963
+ // this check a stopped `?wiki=` would pull+commit+push the DEFAULT repo.
964
+ if (refuseStoppedTarget(req, res)) return
926
965
  if (!beginMutation('restart')) {
927
966
  json(res, { ok: false, error: '另一个重启/同步正在进行中,请稍候' }, 429)
928
967
  return
@@ -955,6 +994,9 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
955
994
  * UI reflects the pulled files instead of looking stale. */
956
995
  const handleSync = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
957
996
  if (rejectCrossSiteWrite(req, res, ['POST'])) return
997
+ // Same reason as /restart: the folder comes from the target, and a stopped
998
+ // `?wiki=` used to fall back to the DEFAULT wiki's repository.
999
+ if (refuseStoppedTarget(req, res)) return
958
1000
  if (!beginMutation('sync')) {
959
1001
  json(res, { ok: false, error: '另一个重启/同步正在进行中,请稍候' }, 429)
960
1002
  return
@@ -1048,6 +1090,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1048
1090
  const handleUpload = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
1049
1091
  try {
1050
1092
  if (rejectCrossSiteWrite(req, res, ['POST'])) return
1093
+ if (refuseStoppedTarget(req, res)) return
1051
1094
  const buf = await readBodyBuffer(req)
1052
1095
  // Name comes from ?name= (URL-encoded) or the X-Filename header.
1053
1096
  const url = new URL(req.url ?? '/', 'http://127.0.0.1')
@@ -29,7 +29,7 @@ export const WECHAT_DOCS_MARKER_TITLE = '$:/plugins/dsh-tiddlywiki/seed-wechat-d
29
29
  * registry's `content` declaration re-exports the same constant, so the
30
30
  * v0.22.0 content-hash bookkeeping guards exactly these bytes).
31
31
  */
32
- export const WECHAT_DOCS_TEXT = "# 微信公众号发布:换机器还原指南\n\n> **这是可选功能,默认关闭,需要额外安装。** 插件本体**不含**它:真正干活的是仓库\n> `tools/wechat/` 下的 opencli adapter + Browser Bridge 浏览器扩展。**不安装/不开启,\n> 插件的其他功能完全不受影响**(不注入发布相关提示词、不往 wiki 写发布规范文档)。\n>\n> 目的:在**另一台机器**上无缝还原「TiddlyWiki 笔记 → 微信公众号」的发布能力。\n> 本机(开发机)已验证全程可跑;本文按顺序照做即可复现。\n>\n> 关键结论先讲:**整套发布走浏览器自动化,不依赖公众号服务端 API**——因为\n> 2025-07 起官方已回收个人主体账号的「发布能力」接口权限(详见 §5)。\n\n---\n\n## 0. 它是可选功能:需要什么、不影响什么\n\n| | 说明 |\n|---|---|\n| **默认状态** | **关闭**(`wechat.enabled` 默认 `false`) |\n| **开启方式** | DSH 设置 →「TiddlyWiki 知识库」→「可选功能:微信公众号发布」→ 勾选 → 保存配置 |\n| **开启后有什么变化** | ① 注入提示词多一行「发布前先读发布元数据规范」;② 启动时把该规范文档写进 wiki(同名不覆盖)。**仅此两项**——不会安装任何东西、不会启用任何后台服务 |\n| **不开启会怎样** | 提示词里没有发布相关文字;wiki 里不会出现「发布元数据规范」;其他功能一切照旧 |\n| **插件会替你做安装吗** | **不会**。opencli 与浏览器扩展必须你手动装(见 §1–§2)——这属于插件外部的工具链 |\n| **要额外装什么** | ① opencli(npm 包)② Browser Bridge 浏览器扩展 ③ 浏览器登录公众号 |\n| **凭据如何保存** | 不保存任何凭据:复用浏览器已有登录态(cookie)。脚本不接触 AppSecret |\n\n> 一句话:**开关只控制「插件要不要配合这个流程」,不负责装工具链。**\n\n---\n\n## 1. 一分钟总览\n\n```\nTiddlyWiki 笔记(tiddler)\n │ 标题\n ▼\nDSH 的 POST /dsh-tiddlywiki/render ← TW 自己渲染成语义 HTML(含代码高亮)\n │ 纯语义 HTML(无内联样式)\n ▼\nwechat-html.js decorate() ← 补内联样式(微信唯一认的形式)\n │ 带内联样式的 HTML\n ▼\nopencli + Browser Bridge 扩展 ← 驱动你已登录的浏览器\n │ 填标题/作者/摘要 + insertHTML 写正文 + 传图 + 设封面 + 存草稿\n ▼\n公众号草稿箱(mp.weixin.qq.com)\n │ (可选 --publish)\n ▼\n点「发表」→ ⚠️ 管理员扫码确认 → 发布成功\n```\n\n**一条命令**(在第二台机器上,装好后):\n\n```bash\nopencli weixin publish-note \"笔记标题\" --trace retain-on-failure -f json\n```\n\n---\n\n## 1. 需要安装/准备的东西\n\n按顺序做;**最后一步才是打开插件里的开关**(开关只影响提示词与文档,装不上工具链)。\n\n| # | 项目 | 怎么装 | 验证 |\n|---|---|---|---|\n| 1 | **Node.js ≥ 22** | 官网安装包 | `node -v` |\n| 2 | **DSH** + **dsh-tiddlywiki 插件** | 本仓库的插件(含 wiki) | `curl http://127.0.0.1:3080/dsh-tiddlywiki/status` |\n| 3 | **opencli** | `npm install -g @jackwener/opencli` | `opencli --version` |\n| 4 | **Browser Bridge 扩展** | 见 §2(二选一) | `opencli doctor` 显示 `Extension: connected` |\n| 5 | **Chrome/Edge + 公众号登录** | 浏览器登录 mp.weixin.qq.com | 能进后台首页 |\n| 6 | **本仓库的 adapter** | `node tools/wechat/install-wechat-adapters.mjs` | 脚本自检输出 ✔ |\n| 7 | **打开插件开关** | 设置 →「TiddlyWiki 知识库」→「可选功能:微信公众号发布」勾选 → 保存配置 | 提示词预览里出现「发布元数据规范」一行 |\n\n> **opencli 版本建议 ≥ 1.8.7**(本方案在该版本实测通过;1.7.x 的 `browser`\n> 子命令语法不同,且内置 weixin adapter 不完整)。\n>\n> **新版 npm 的 allowScripts 机制会拦 opencli 的 postinstall 脚本**(安装时打\n> warning):**无害,可忽略**——postinstall 只装 bash/zsh/fish 补全(Windows 用不上),\n> adapters 随 npm 包自带,功能不受影响。\n>\n> 第 7 步之前,插件不会注入任何发布相关提示词、也不会往 wiki 写「发布元数据规范」。\n> 也就是说:**没装好工具链时开关可以一直关着,不影响任何其他功能。**\n\n### 自动化的终点:草稿箱(2026-09-17 与用户确认的策略)\n\n**adapter 负责到「草稿落盘」为止**:填标题/作者/摘要、写正文、传图、设封面、存草稿。\n**发表由人工在后台完成**——点「发表」时平台会弹确认对话框(原创声明、作者、留言\n设置等)以及最后的管理员扫码;原创声明涉及账号权益,人工点最稳妥。`--publish`\n选项保留(best-effort:点「发表」后轮询等扫码),但**不处理中途弹窗**,遇到弹窗\n会一直等到超时。\n\n---\n\n## 2. 安装 Browser Bridge 扩展(关键前置)\n\n扩展**不在 npm 包里**,必须单独装。二选一:\n\n**方式 A — Chrome Web Store(推荐)**\n1. 打开 https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk\n2. 点「添加至 Chrome」\n\n**方式 B — GitHub Releases(Web Store 打不开时)**\n1. 到 https://github.com/jackwener/opencli/releases 下载 `opencli-extension-v*.zip`\n2. 解压到某个固定目录\n3. 浏览器打开 `chrome://extensions` → 开启右上角「开发者模式」\n4. 点「加载已解压的扩展程序」→ 选择解压出的目录\n\n> ⚠️ GitHub 的 ext release **经常落后于 Web Store**(2026-09-17 实测只到\n> `ext-v1.0.21`,Web Store 已是 1.0.24)。**扩展版本以 Chrome Web Store 为准**;\n> GitHub 方式只作 Web Store 打不开时的离线兜底。\n\n**验证**:\n\n```bash\nopencli doctor\n# 期望: [OK] Daemon: running ...\n# [OK] Extension: connected (vX.Y.Z)\n# [OK] Connectivity: connected\n# Everything looks good!\n```\n\n若报 `Extension: not connected`:确认浏览器在运行、扩展已启用;再 `opencli daemon stop` 后重跑 `opencli doctor`(daemon 会自动重启)。\n\n---\n\n## 3. 装 adapter 并试跑\n\n```bash\ncd <本仓库>\nnode tools/wechat/install-wechat-adapters.mjs\n```\n\n脚本会把 `tools/wechat/*.js` 复制到 `~/.opencli/clis/weixin/`(Windows:`C:\\Users\\<你>\\.opencli\\clis\\weixin\\`)。\n这些是**私有 adapter**,放在该目录即可被 opencli 自动发现,**无需任何构建**。\n\n然后:\n\n```bash\n# ① 先干跑:只做 TW 渲染 + 排版装饰,导出预览,不碰微信\nopencli weixin publish-note \"某篇笔记标题\" --preview ./out --trace retain-on-failure\n\n# → 用浏览器打开 ./out.html 肉眼确认排版\n# (注意:--preview 仍会继续执行发布流程;只想预览时看文件即可,或 Ctrl-C)\n\n# ② 真发到草稿箱\nopencli weixin publish-note \"某篇笔记标题\" --trace retain-on-failure -f json\n\n# ③ 带封面(公众号封面必填,否则草稿显示\"内容不完整\")\nopencli weixin publish-note \"某篇笔记标题\" --cover ./cover.png --trace retain-on-failure -f json\n\n# ④ 直接发表(⚠️ 会真发文章,需管理员扫码)\nopencli weixin publish-note \"某篇笔记标题\" --publish --trace retain-on-failure -f json\n\n# ⑤ 正文多图(v0.23.2+):TW 笔记内嵌 [img[...]] 经 /render 变成 data URI,\n# publish-note 传不了(微信存草稿时过滤非 mmbiz 图)——用 publish-note-imgs:\nopencli weixin publish-note-imgs \"某篇笔记标题\" --images <图片目录> --trace retain-on-failure -f json\nopencli weixin publish-note-imgs \"某篇笔记标题\" --images \"01.png|02.png|03.png\" -f json\n# · --images 传目录时按文件名排序 = 正文图片出现顺序(封面图排第一)\n# · 也可用 | 分隔的路径列表精确指定顺序\n# · 流程:先逐张上传 CDN → 按 DOM 顺序重写正文 src → insertHTML → 选封面 → 存草稿\n# · 发表前同样读 pub-state / no-publish(只告警不阻断),--publish 可直接发表\n```\n\n### 3.6 在 TiddlyWiki 里点按钮发布(v0.23.3,日常推荐)\n\n不想敲命令时,用**笔记工具栏的「发布到公众号」按钮**(嵌入式 TW 面板、右侧栏 tab、原生编辑弹窗里都在):\n\n1. 设置页勾选「可选功能:微信公众号发布」并保存 → 重启 dsh web 后,启动 seed 会把按钮插件\n `$:/plugins/dsh/wechat-publish` 写进 wiki(老 wiki 也可在设置页「初始化」区单独写它)。\n2. 打开任意笔记 → 工具栏点「发布到公众号」→ 按钮**先预检**(opencli 跑不跑得起来、adapter 缺哪些文件),\n 再弹确认框(自动列出 `no-publish` / `pub-state` 警告)→ 点「开始存草稿」。\n3. 宿主进程起一个**后台任务**,覆盖层每 2 秒显示状态与日志尾部。**只到草稿箱为止,不自动发表。**\n\n按钮背后的三条路由(同源;写操作有方法 + CSRF 守卫;`wechat.enabled` 关着时一律 403):\n\n| 路由 | 方法 | 作用 |\n|---|---|---|\n| `/dsh-tiddlywiki/wechat/ready` | GET | 预检:`opencli --version` 能否跑通、adapter 缺哪些文件 |\n| `/dsh-tiddlywiki/wechat/publish` | POST | 起任务(body `{title, adapter?}`);单并发,第二次调用 409 |\n| `/dsh-tiddlywiki/wechat/publish/status` | GET | 轮询任务(`?id=`;不带 id 取最新/正在跑的那个) |\n\n可配项(设置页目前只暴露 `enabled`,其余写进配置 tiddler\n`$:/plugins/dsh-tiddlywiki/config` 的 `wechat` 块,保存即生效):\n\n| 键 | 默认 | 说明 |\n|---|---|---|\n| `wechat.enabled` | `false` | 总开关;关着时按钮不写入、三条路由 403 |\n| `wechat.adapter` | `publish-note` | 换成 `publish-note-imgs` 走正文内嵌多图(需该 adapter 已装) |\n| `wechat.command` | `opencli` | CLI 路径(装了别名、不在 PATH 时用) |\n| `wechat.token` | 空 | 非空时要求请求头 `x-wechat-publish-token`(按钮会自动带上) |\n| `wechat.dsn` | 空 | adapter 回连 DSH 的基址;留空按请求端口推导 `http://127.0.0.1:<端口>/dsh-tiddlywiki` |\n| `wechat.endpoint` | 空 | **只被 TW 侧读**:覆盖按钮请求的基址(默认 `location.origin + /dsh-tiddlywiki`);反向代理/远程访问场景用 |\n\n⚠️ 宿主用 **`--title-file`**(v0.23.3 新增参数)把标题经 UTF-8 文件交给 adapter:标题不进 argv,\n否则 Windows 上 `opencli` 的 `.cmd` shim 会把 `&`/`|`/`^` 当命令分隔符,中文标题还会被 cmd 的\n代码页解码成乱码。位置参数 `<title>` 照旧可用(两者都给时位置参数优先)。\n⚠️ 按钮**不替你发表**:发表要管理员扫码(§4.2),请在公众号后台点「发表」。\n\n---\n\n## 3.5 发布元数据(重要:避免重发/误发)\n\nwiki 里既有**早已发布**的文章,也有**明确不能发**的内容。没有记录,agent 只能猜——\n要么重复发已上线的,要么把不该发的推出去。\n\n**规范全文在 wiki 里**:笔记「**发布元数据规范**」(随插件 seed 分发,`dsh-docs` 标签,\n开机自动写入;也可手动跑 `npx tsx tools/wechat/seed-publish-spec-now.mts` 立即写入)。\n注入给 agent 的系统提示词里只有一句指针(slim 预算只剩几十字符,放不下全文)。\n\n### 字段(自定义字段,不是标签)\n\n| 字段 | 含义 | 示例 |\n|---|---|---|\n| `pub-state` | 能不能发 | `draft` / `published` / `excluded` |\n| `pub-platform` | 最近发布的平台(多平台逗号分隔) | `wechat` |\n| `pub-wechat-at` | 公众号发表时间 | `2026-09-17 15:30` |\n| `pub-wechat-title` | 发表时的标题(**仅当与 TW 标题不同才写**) | `中年失业自救指南` |\n| `pub-wechat-url` | 发表后的链接 | `https://mp.weixin.qq.com/s/xxx` |\n| `pub-note` | 备注 | `删改后重发过一版` |\n\n**没有 `pub-state` = 未知状态**,应当当作「需要人确认」,而不是「可以随便发」。\n\n标签 `no-publish` 是给人看的镜像(TW 界面一眼可见、可筛);`pub-state: excluded` 给\nagent 程序化判断。**两者应一致,冲突时以字段为准。**\n\n### ⚠️ 命名冲突(别占用)\n\n实测本 wiki 里这三个名字**已被占用**,方案特意避开:\n\n- `publish` / `publishyear` 字段 → Obsidian 导入的**书籍**条目用作「出版社 / 出版年」\n (值形如「新世界出版社」「2011-09-01」)。\n- `发布记录` 标签 → 本插件自己的**版本发布说明**。\n\n### 发布前检查:只告警,不阻断\n\n`publish-note` 会读 `pub-state` / `no-publish`,命中就打印醒目警告,**然后继续执行**\n(2026-09-17 明确选定的策略)。`--force` 只改措辞,不改变行为:\n\n```\n⚠️ 「某篇」已发布过(平台 wechat)。这可能是重复发布——确认无误再继续;确实要重发请加 --force。\n```\n\n### 回写(agent 负责)\n\n发布成功后,命令的 `detail` 里会带一句**建议回写值**,例如:\n\n```\n建议回写:pub-state=published, pub-platform=wechat, pub-wechat-at=\"2026-09-17 17:50\"\n```\n\nagent 据此用 `tiddlywiki_put` 写回(**不传 `tags` 就保留原标签**,只补 `fields`):\n\n```\ntiddlywiki_put(title=\"某篇\", text=<原正文>, fields={\"pub-state\":\"published\", \"pub-platform\":\"wechat\", \"pub-wechat-at\":\"...\"})\n```\n\n> ⚠️ 用工具时 `text` 会被覆盖,**务必带上原正文**。别像我一样用裸 REST PUT 只发一段\n> 文字——那会把笔记正文清空(真实踩过,靠 git 恢复)。\n\n### 存量回填\n\n早于本规范、来源可辨(`source-path` 含「公众号」)的文章,用脚本回填:\n\n```bash\n# 默认 dry-run,只打印将改什么\nnode tools/wechat/backfill-publish-state.mjs\n\n# 确认后真写\nnode tools/wechat/backfill-publish-state.mjs --write\n\n# 指定标题 / 平台 / 匹配子串\nnode tools/wechat/backfill-publish-state.mjs --title \"打工记\" --title \"鱼\" --write\nnode tools/wechat/backfill-publish-state.mjs --match 公众号 --platform wechat --write\n```\n\n脚本纪律(**请勿改成「只写字段」的裸 PUT**):每条都先 GET **完整** tiddler(含\ntext/tags/type/created),**只添加** `pub-*` 字段后整体写回;已有 `pub-state` 的跳过\n(幂等);正文为空的跳过(宁可不动);**发表时间无法考证就留空,不编造**。\n\n---\n\n## 4. ⚠️ 三个必须知道的坑\n\n### 4.1 所有命令都要带 `--trace retain-on-failure`\n\n不带会对 `mp.weixin.qq.com` 稳定报:\n\n```\nNavigation rejected\n```\n\n**实测**:trace 开 → 5/5 成功;trace 关 → 8/8 失败。这是 opencli 1.8.7 的 bug\n(源码里 trace 只做录像,理论上不该影响导航)。已试过无效的绕法:\n`--site-session ephemeral`、`--keep-tab false`、`--window foreground/background`、\n重置标签页、重启 daemon。**唯一有效就是加 trace。**\n\n### 4.2 发表必须管理员扫码(无法自动化)\n\n后台点「发表」后,微信要求**管理员微信扫码确认**。这不是技术能绕开的账号安全机制。\n所以「一键发布」的真实含义是:\n\n> agent/脚本 完成素材、排版、上传、填表、进草稿箱 —— **你只需扫一次码**。\n\n`--publish` 会自动点「发表」并在终端提示你扫码,然后轮询结果(默认等 180s,\n可用 `--timeout` 调整)。\n\n### 4.3 「发表」≠「群发」(别搞混)\n\n| 操作 | 推送粉丝 | 占群发额度 | 需要扫码 |\n|---|---|---|---|\n| **发表**(本方案默认) | 否(仅生成永久链接) | 否,不限次数 | 是 |\n| **群发** | 是 | 个人订阅号 1 天 1 次 | 是 |\n\n两者都要扫码 → 默认选**不吃额度**的「发表」。\n\n### 4.4 发表阶段的确认弹窗 adapter 不处理(v0.23.x 现状)\n\n`publishDraft` 点完「发表」后**只轮询成功信号**,不会点任何平台弹窗。发表链路上\n还有:原创声明/作者/留言设置等**确认对话框**(2026-09-17 人工发表实测必经)与最后\n的管理员扫码。所以**推荐流程到草稿箱为止**(见 §1),发表环节人工做;`--publish`\n只是 best-effort,遇到弹窗会卡到超时。排错见 §8。\n\n---\n\n## 5. 为什么不用官方 API(背景,避免走回头路)\n\n公众号发布的服务端链路是 `access_token → draft/add → freepublish/submit`。但:\n\n- **2025 年 7 月起,官方回收「发布能力」接口对个人主体、企业未认证、不支持认证账号的调用权限**\n ([发布能力文档](https://developers.weixin.qq.com/doc/subscription/guide/product/publish.html) 原文注)。\n 个人主体无法做微信认证 → `freepublish/submit` 不可用。\n- 草稿箱 `draft/add` 文档未列该限制,但 48001 / `api unauthorized` 是常见返回。\n- 即便可用,还叠加:**本机公网出口 IP 必须加入 API IP 白名单**(否则 61004/40164)、\n 封面 `thumb_media_id` 必须是**永久素材**、正文图片必须是 `media/uploadimg` 产出的\n mmbiz 地址(外链图被过滤)。\n\n**而后台网页端从未受影响**——这是所有个人号日常发文的方式。所以本方案驱动网页端。\n\n---\n\n## 6. 文件清单(本方案包含什么)\n\n```\ntools/wechat/\n├── wechat-html.js # 排版装饰器:语义 HTML → 内联样式 HTML(主题表在这里改)\n├── weixin-flow.js # 浏览器流程:登录检测/填表/写正文/传图/设封面/存草稿/发表\n├── create-article.js # 命令:从本地 HTML 文件建草稿(可选发表)\n├── publish-note.js # 命令:从 TW 笔记建草稿(主入口,含 --preview;单图 --cover)\n├── publish-note-imgs.js # 命令:多图版——正文内嵌 data URI 图全部上传 CDN 再发布\n└── install-wechat-adapters.mjs # 一键安装到 ~/.opencli/clis/weixin/\n```\n\n装到本机后对应 `~/.opencli/clis/weixin/` 下的同名文件。\n改排版只需改 `tools/wechat/wechat-html.js` 里的 `ELEMENT_STYLES`,重跑安装脚本即可。\n\n---\n\n## 7. 技术实现要点(改代码前必读)\n\n### 7.1 正文写入必须用 `insertHTML`,不能用 `insertText`\n\n微信编辑器是 **ProseMirror**。`document.execCommand('insertHTML', …)` 保留内联样式;\n`insertText` 会把 HTML/Markdown 当**字面文本**(opencli 内置的 `weixin create-draft`\n就是这样——实测后台 digest 里 `# 一级标题` 和 `**加粗**` 的符号原样保留)。\n\n### 7.2 图片上传必须用 DataTransfer,不能用 `page.setFileInput`\n\n`page.setFileInput` 依赖 CDP 的 `Page.fileChooserOpened` 事件,本机(opencli 1.8.7 +\n扩展 v1.0.24 + Edge)**稳定失败**:\n\n```\nPage.fileChooserOpened not received within 5s\n```\n\n([issue #1582](https://github.com/jackwener/OpenCLI/issues/1582) 显示扩展/CLI 版本不匹配确有历史问题。)\n\n**绕过办法**——在页面上下文用 DataTransfer 直接把 File 塞进 `input.files`:\n\n```js\nconst bin = atob(base64)\nconst arr = new Uint8Array(bin.length)\nfor (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)\nconst file = new File([arr], 'cover.png', { type: 'image/png' })\nconst dt = new DataTransfer()\ndt.items.add(file)\ninput.files = dt.files\ninput.dispatchEvent(new Event('change', { bubbles: true }))\n```\n\n实测图片真的上了 `mmbiz.qpic.cn`。**代价:字节要以 base64 穿过 evaluate → 单图限 8MB。**\n\n### 7.3 微信编辑器会剥 `<style>` 和 class,只认内联 `style`\n\n实测:注入 `<style>h1{color:red}</style>` → `hasStyleTag: false`;\n注入 `style=\"font-size:20px\"` → 原样保留。\n而 **TiddlyWiki 的 `/render` 输出零内联样式**(纯语义 HTML + class)——\n这正是需要 `wechat-html.js` 装饰器的原因。\n\n### 7.4 轮询里绝不能读 `document.body.innerText`(v0.23.0 实测踩坑)\n\n微信编辑器 DOM 极大,读一次 `body.innerText` **强制整页 layout,实测单次 evaluate\n≈17 秒**。早期 `saveDraft` / `publishDraft` 的轮询用它找成功提示,8 次轮询把整个命令\n拖过 210s 超时——**最坑的是草稿其实已经保存成功**,用户看到的是超时失败(假阴性)。\n\n现在只查少量 toast 节点(`.weui-desktop-toast` / `.weui-desktop-msg` / `#js_save_success`)\n和 URL,都是 O(1) 操作。修复后 `saveDraft` 从 **137 秒降到 3.9 秒**。\n\n`scripts/verify-wechat-adapters.mjs` 有回归守门(已反向验证:塞回去即红)。\n\n### 7.5 其他踩过的坑\n\n- 图片下拉必须**按文案**点「本地上传」:用 `items[0]` 点完 file input 仍不可见。\n- 正文是编辑器页**最后一个** `contenteditable`。\n- 回读校验必须**忽略所有空白**:编辑器 innerText 会在块级元素间插空白、把空格转 nbsp,\n 直接用原始字符串比对必假阴性。\n- 保存按钮的文案兜底**特意不含「发表/发布」**,防止误点发布。\n- 草稿列表显示「图文内容不完整」**只表示缺封面**,不代表写入失败。\n- `<pre>` 内的 `<code>` 不能套用行内代码样式(粉底 + 内边距会很难看),\n `wechat-html.js` 里用 `preDepth` 特判。\n\n---\n\n## 8. 排错\n\n| 现象 | 原因 / 处理 |\n|---|---|\n| `Navigation rejected` | 忘了 `--trace retain-on-failure` |\n| `AUTH_REQUIRED` / 提示登录 | 浏览器里 `mp.weixin.qq.com` 登录态过期 → 重新扫码登录 |\n| `Extension: not connected` | 扩展没装/没启用,或浏览器没运行 → 见 §2 |\n| `Page.fileChooserOpened not received` | 不应再出现(已改用 DataTransfer);若出现说明源码被改回 setFileInput |\n| 找不到笔记 | tiddler 标题要**完全精确**(含空格/标点);`opencli weixin publish-note \"标题\"` |\n| `/render 返回 HTTP 404` | 标题不存在;先用 `tiddlywiki_search` 或 TW 面板确认 |\n| 无法连接 DSH | `--dsn` 默认 `http://127.0.0.1:3080/dsh-tiddlywiki`;DSH 没跑或端口不同就改它 |\n| 草稿显示「内容不完整」 | 缺封面 → 传 `--cover`(或用 publish-note-imgs 从正文第一张自动设) |\n| 图片没上传 | 单图 > 8MB(DataTransfer 限制)→ 先压缩 |\n| 发表卡住超时 | 没扫码,或扫码没完成 → 调大 `--timeout`;若卡在原创声明/留言等确认弹窗 → 推荐流程本就到草稿箱为止,到后台人工点发表(见 §1、§4.4) |\n| `stale page identity` | 命令执行中标签页被手工关闭/导航了。**草稿不丢**——重开编辑页续作:`https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&type=77&appmsgid=<id>&idx=0&token=<token>`(token 用 `opencli browser wx eval 'location.href.match(/token=(\\d+)/)[1]'` 从后台首页取) |\n| 封面警告「设置失败」但草稿其实有封面 | 已知**误报**(校验时机太早);以草稿箱实际显示为准(2026-09-17 实测:`list_ex` 接口 `cover` 字段已是 mmbiz 地址)。v0.23.2 起改为轮询校验 |\n| 用 eval 诊断后台状态时读到「未实名」「未设置头像和名称」等提示 | ⚠️ 后台 DOM 里常残留**不可见的历史 toast 节点**——查询必须过滤可见性(`offsetHeight > 0`),勿把残留文案当实时状态(2026-09-17 踩过:据此误判账号被平台拦截,实际账号正常并成功发表含原创声明)。同理,判断原创声明是否生效以草稿箱/发表记录为准,勿只看侧栏文案 |\n| 需要核实草稿是否落盘 | 后台 ajax:`/cgi-bin/appmsg?action=list_ex&type=77&orderby=create_time&token=<token>&f=json&begin=0&count=5`(在 mp.weixin.qq.com 页面上下文执行;`cover`/`digest`/`update_time` 一目了然);发表记录:`/cgi-bin/appmsgpublish?sub=list&...&f=json`(`publish_page.publish_list[].publish_info` 里是 JSON 字符串,含 `content_url`) |\n\n---\n\n## 9. 安全与隐私\n\n- **不存任何公众号凭据**:全程复用浏览器已有登录态(cookie),脚本不接触 AppSecret。\n- **不落盘密钥**:adapter 只读本地图片路径,不写任何 token。\n- **低频使用**:不做规避风控的行为伪装;默认走「发表」(不推送粉丝、不占额度)。\n- wiki 里的配置 tiddler 与本方案无关——本方案零配置。\n\n---\n\n## 10. 参考\n\n- [opencli](https://github.com/jackwener/opencli) — 把网站变成 CLI,复用浏览器登录态\n- [微信「发布能力」文档(2025-07 权限回收)](https://developers.weixin.qq.com/doc/subscription/guide/product/publish.html)\n- [微信「新增草稿」draft/add](https://developers.weixin.qq.com/doc/subscription/api/draftbox/draftmanage/api_draft_add.html)\n- [微信服务端 API 调用说明(IP 白名单)](https://developers.weixin.qq.com/doc/subscription/guide/dev/api/)\n- 设计文档:`docs/plans/2026-09-17-wechat-publish-design.md`\n"
32
+ export const WECHAT_DOCS_TEXT = "# 微信公众号发布:换机器还原指南\n\n> **这是可选功能,默认关闭,需要额外安装。** 插件本体**不含**它:真正干活的是仓库\n> `tools/wechat/` 下的 opencli adapter + Browser Bridge 浏览器扩展。**不安装/不开启,\n> 插件的其他功能完全不受影响**(不注入发布相关提示词、不往 wiki 写发布规范文档)。\n>\n> 目的:在**另一台机器**上无缝还原「TiddlyWiki 笔记 → 微信公众号」的发布能力。\n> 本机(开发机)已验证全程可跑;本文按顺序照做即可复现。\n>\n> 关键结论先讲:**整套发布走浏览器自动化,不依赖公众号服务端 API**——因为\n> 2025-07 起官方已回收个人主体账号的「发布能力」接口权限(详见 §5)。\n\n---\n\n## 0. 它是可选功能:需要什么、不影响什么\n\n| | 说明 |\n|---|---|\n| **默认状态** | **关闭**(`wechat.enabled` 默认 `false`) |\n| **开启方式** | DSH 设置 →「TiddlyWiki 知识库」→「可选功能:微信公众号发布」→ 勾选 → 保存配置 |\n| **开启后有什么变化** | ① 注入提示词多一行「发布前先读发布元数据规范」;② 启动时把该规范文档写进 wiki(同名不覆盖)。**仅此两项**——不会安装任何东西、不会启用任何后台服务 |\n| **不开启会怎样** | 提示词里没有发布相关文字;wiki 里不会出现「发布元数据规范」;其他功能一切照旧 |\n| **插件会替你做安装吗** | **不会**。opencli 与浏览器扩展必须你手动装(见 §1–§2)——这属于插件外部的工具链 |\n| **要额外装什么** | ① opencli(npm 包)② Browser Bridge 浏览器扩展 ③ 浏览器登录公众号 |\n| **凭据如何保存** | 不保存任何凭据:复用浏览器已有登录态(cookie)。脚本不接触 AppSecret |\n\n> 一句话:**开关只控制「插件要不要配合这个流程」,不负责装工具链。**\n\n---\n\n## 1. 一分钟总览\n\n```\nTiddlyWiki 笔记(tiddler)\n │ 标题\n ▼\nDSH 的 POST /dsh-tiddlywiki/render ← TW 自己渲染成语义 HTML(含代码高亮)\n │ 纯语义 HTML(无内联样式)\n ▼\nwechat-html.js decorate() ← 补内联样式(微信唯一认的形式)\n │ 带内联样式的 HTML\n ▼\nopencli + Browser Bridge 扩展 ← 驱动你已登录的浏览器\n │ 填标题/作者/摘要 + insertHTML 写正文 + 传图 + 设封面 + 存草稿\n ▼\n公众号草稿箱(mp.weixin.qq.com)\n │ (可选 --publish)\n ▼\n点「发表」→ ⚠️ 管理员扫码确认 → 发布成功\n```\n\n**一条命令**(在第二台机器上,装好后):\n\n```bash\nopencli weixin publish-note \"笔记标题\" --trace retain-on-failure -f json\n```\n\n---\n\n## 1. 需要安装/准备的东西\n\n按顺序做;**最后一步才是打开插件里的开关**(开关只影响提示词与文档,装不上工具链)。\n\n| # | 项目 | 怎么装 | 验证 |\n|---|---|---|---|\n| 1 | **Node.js ≥ 22** | 官网安装包 | `node -v` |\n| 2 | **DSH** + **dsh-tiddlywiki 插件** | 本仓库的插件(含 wiki) | `curl http://127.0.0.1:3080/dsh-tiddlywiki/status` |\n| 3 | **opencli** | `npm install -g @jackwener/opencli` | `opencli --version` |\n| 4 | **Browser Bridge 扩展** | 见 §2(二选一) | `opencli doctor` 显示 `Extension: connected` |\n| 5 | **Chrome/Edge + 公众号登录** | 浏览器登录 mp.weixin.qq.com | 能进后台首页 |\n| 6 | **本仓库的 adapter** | `node tools/wechat/install-wechat-adapters.mjs` | 脚本自检输出 ✔ |\n| 7 | **打开插件开关** | 设置 →「TiddlyWiki 知识库」→「可选功能:微信公众号发布」勾选 → 保存配置 | 提示词预览里出现「发布元数据规范」一行 |\n\n> **opencli 版本建议 ≥ 1.8.7**(本方案在该版本实测通过;1.7.x 的 `browser`\n> 子命令语法不同,且内置 weixin adapter 不完整)。\n>\n> **新版 npm 的 allowScripts 机制会拦 opencli 的 postinstall 脚本**(安装时打\n> warning):**无害,可忽略**——postinstall 只装 bash/zsh/fish 补全(Windows 用不上),\n> adapters 随 npm 包自带,功能不受影响。\n>\n> 第 7 步之前,插件不会注入任何发布相关提示词、也不会往 wiki 写「发布元数据规范」。\n> 也就是说:**没装好工具链时开关可以一直关着,不影响任何其他功能。**\n\n### 自动化的终点:草稿箱(2026-09-17 与用户确认的策略)\n\n**adapter 负责到「草稿落盘」为止**:填标题/作者/摘要、写正文、传图、设封面、存草稿。\n**发表由人工在后台完成**——点「发表」时平台会弹确认对话框(原创声明、作者、留言\n设置等)以及最后的管理员扫码;原创声明涉及账号权益,人工点最稳妥。`--publish`\n选项保留(best-effort:点「发表」后轮询等扫码),但**不处理中途弹窗**,遇到弹窗\n会一直等到超时。\n\n---\n\n## 2. 安装 Browser Bridge 扩展(关键前置)\n\n扩展**不在 npm 包里**,必须单独装。二选一:\n\n**方式 A — Chrome Web Store(推荐)**\n1. 打开 https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk\n2. 点「添加至 Chrome」\n\n**方式 B — GitHub Releases(Web Store 打不开时)**\n1. 到 https://github.com/jackwener/opencli/releases 下载 `opencli-extension-v*.zip`\n2. 解压到某个固定目录\n3. 浏览器打开 `chrome://extensions` → 开启右上角「开发者模式」\n4. 点「加载已解压的扩展程序」→ 选择解压出的目录\n\n> ⚠️ GitHub 的 ext release **经常落后于 Web Store**(2026-09-17 实测只到\n> `ext-v1.0.21`,Web Store 已是 1.0.24)。**扩展版本以 Chrome Web Store 为准**;\n> GitHub 方式只作 Web Store 打不开时的离线兜底。\n\n**验证**:\n\n```bash\nopencli doctor\n# 期望: [OK] Daemon: running ...\n# [OK] Extension: connected (vX.Y.Z)\n# [OK] Connectivity: connected\n# Everything looks good!\n```\n\n若报 `Extension: not connected`:确认浏览器在运行、扩展已启用;再 `opencli daemon stop` 后重跑 `opencli doctor`(daemon 会自动重启)。\n\n---\n\n## 3. 装 adapter 并试跑\n\n```bash\ncd <本仓库>\nnode tools/wechat/install-wechat-adapters.mjs\n```\n\n脚本会把 `tools/wechat/*.js` 复制到 `~/.opencli/clis/weixin/`(Windows:`C:\\Users\\<你>\\.opencli\\clis\\weixin\\`)。\n这些是**私有 adapter**,放在该目录即可被 opencli 自动发现,**无需任何构建**。\n\n然后:\n\n```bash\n# ① 先干跑:只做 TW 渲染 + 排版装饰,导出预览,不碰微信\nopencli weixin publish-note \"某篇笔记标题\" --preview ./out --trace retain-on-failure\n\n# → 用浏览器打开 ./out.html 肉眼确认排版\n# (注意:--preview 仍会继续执行发布流程;只想预览时看文件即可,或 Ctrl-C)\n\n# ② 真发到草稿箱\nopencli weixin publish-note \"某篇笔记标题\" --trace retain-on-failure -f json\n\n# ③ 带封面(公众号封面必填,否则草稿显示\"内容不完整\")\nopencli weixin publish-note \"某篇笔记标题\" --cover ./cover.png --trace retain-on-failure -f json\n\n# ④ 直接发表(⚠️ 会真发文章,需管理员扫码)\nopencli weixin publish-note \"某篇笔记标题\" --publish --trace retain-on-failure -f json\n\n# ⑤ 正文多图(v0.23.2+):TW 笔记内嵌 [img[...]] 经 /render 变成 data URI,\n# publish-note 传不了(微信存草稿时过滤非 mmbiz 图)——用 publish-note-imgs:\nopencli weixin publish-note-imgs \"某篇笔记标题\" --images <图片目录> --trace retain-on-failure -f json\nopencli weixin publish-note-imgs \"某篇笔记标题\" --images \"01.png|02.png|03.png\" -f json\n# · --images 传目录时按文件名排序 = 正文图片出现顺序(封面图排第一)\n# · 也可用 | 分隔的路径列表精确指定顺序\n# · 流程:先逐张上传 CDN → 按 DOM 顺序重写正文 src → insertHTML → 选封面 → 存草稿\n# · 发表前同样读 pub-state / no-publish(只告警不阻断),--publish 可直接发表\n```\n\n### 3.6 在 TiddlyWiki 里点按钮发布(v0.23.3,日常推荐)\n\n不想敲命令时,用**笔记工具栏的「发布到公众号」按钮**(嵌入式 TW 面板、右侧栏 tab、原生编辑弹窗里都在):\n\n1. 设置页勾选「可选功能:微信公众号发布」并保存 → 重启 dsh web 后,启动 seed 会把按钮插件\n `$:/plugins/dsh/wechat-publish` 写进 wiki(老 wiki 也可在设置页「初始化」区单独写它)。\n2. 打开任意笔记 → 工具栏点「发布到公众号」→ 按钮**先预检**(opencli 跑不跑得起来、adapter 缺哪些文件),\n 再弹确认框(自动列出 `no-publish` / `pub-state` 警告)→ 点「开始存草稿」。\n3. 宿主进程起一个**后台任务**,覆盖层每 2 秒显示状态与日志尾部。**只到草稿箱为止,不自动发表。**\n\n按钮背后的三条路由(同源;写操作有方法 + CSRF 守卫;`wechat.enabled` 关着时一律 403):\n\n| 路由 | 方法 | 作用 |\n|---|---|---|\n| `/dsh-tiddlywiki/wechat/ready` | GET | 预检:`opencli --version` 能否跑通、adapter 缺哪些文件 |\n| `/dsh-tiddlywiki/wechat/publish` | POST | 起任务(body `{title, adapter?}`);单并发,第二次调用 409 |\n| `/dsh-tiddlywiki/wechat/publish/status` | GET | 轮询任务(`?id=`;不带 id 取最新/正在跑的那个) |\n\n可配项(设置页目前只暴露 `enabled`,其余写进配置 tiddler\n`$:/plugins/dsh-tiddlywiki/config` 的 `wechat` 块,保存即生效):\n\n| 键 | 默认 | 说明 |\n|---|---|---|\n| `wechat.enabled` | `false` | 总开关;关着时按钮不写入、三条路由 403 |\n| `wechat.adapter` | `publish-note` | 换成 `publish-note-imgs` 走正文内嵌多图(需该 adapter 已装) |\n| `wechat.command` | `opencli` | CLI 路径(装了别名、不在 PATH 时用) |\n| `wechat.token` | 空 | 非空时要求请求头 `x-wechat-publish-token`(按钮会自动带上) |\n| `wechat.dsn` | 空 | adapter 回连 DSH 的基址;留空按请求端口推导 `http://127.0.0.1:<端口>/dsh-tiddlywiki` |\n| `wechat.endpoint` | 空 | **只被 TW 侧读**:覆盖按钮请求的基址(默认 `location.origin + /dsh-tiddlywiki`);反向代理/远程访问场景用 |\n\n⚠️ 宿主用 **`--title-file`**(v0.23.3 新增参数)把标题经 UTF-8 文件交给 adapter:标题不进 argv,\n否则 Windows 上 `opencli` 的 `.cmd` shim 会把 `&`/`|`/`^` 当命令分隔符,中文标题还会被 cmd 的\n代码页解码成乱码。位置参数 `<title>` 照旧可用(两者都给时位置参数优先)。\n⚠️ 按钮**不替你发表**:发表要管理员扫码(§4.2),请在公众号后台点「发表」。\n\n---\n\n## 3.5 发布元数据(重要:避免重发/误发)\n\nwiki 里既有**早已发布**的文章,也有**明确不能发**的内容。没有记录,agent 只能猜——\n要么重复发已上线的,要么把不该发的推出去。\n\n**规范全文在 wiki 里**:笔记「**发布元数据规范**」(随插件 seed 分发,`dsh-docs` 标签,\n开机自动写入;也可手动跑 `npx tsx tools/wechat/seed-publish-spec-now.mts` 立即写入)。\n注入给 agent 的系统提示词里**只有一句指针**(规范全文太长,不适合放进每个会话都要付的常驻预算 —— v0.29.0 把 slim 从 ~1.9KB 瘦到 ~1.0KB 之后依然如此,指针就够了:需要时 Agent 会去读那篇笔记)。\n\n### 字段(自定义字段,不是标签)\n\n| 字段 | 含义 | 示例 |\n|---|---|---|\n| `pub-state` | 能不能发 | `draft` / `published` / `excluded` |\n| `pub-platform` | 最近发布的平台(多平台逗号分隔) | `wechat` |\n| `pub-wechat-at` | 公众号发表时间 | `2026-09-17 15:30` |\n| `pub-wechat-title` | 发表时的标题(**仅当与 TW 标题不同才写**) | `中年失业自救指南` |\n| `pub-wechat-url` | 发表后的链接 | `https://mp.weixin.qq.com/s/xxx` |\n| `pub-note` | 备注 | `删改后重发过一版` |\n\n**没有 `pub-state` = 未知状态**,应当当作「需要人确认」,而不是「可以随便发」。\n\n标签 `no-publish` 是给人看的镜像(TW 界面一眼可见、可筛);`pub-state: excluded` 给\nagent 程序化判断。**两者应一致,冲突时以字段为准。**\n\n### ⚠️ 命名冲突(别占用)\n\n实测本 wiki 里这三个名字**已被占用**,方案特意避开:\n\n- `publish` / `publishyear` 字段 → Obsidian 导入的**书籍**条目用作「出版社 / 出版年」\n (值形如「新世界出版社」「2011-09-01」)。\n- `发布记录` 标签 → 本插件自己的**版本发布说明**。\n\n### 发布前检查:只告警,不阻断\n\n`publish-note` 会读 `pub-state` / `no-publish`,命中就打印醒目警告,**然后继续执行**\n(2026-09-17 明确选定的策略)。`--force` 只改措辞,不改变行为:\n\n```\n⚠️ 「某篇」已发布过(平台 wechat)。这可能是重复发布——确认无误再继续;确实要重发请加 --force。\n```\n\n### 回写(agent 负责)\n\n发布成功后,命令的 `detail` 里会带一句**建议回写值**,例如:\n\n```\n建议回写:pub-state=published, pub-platform=wechat, pub-wechat-at=\"2026-09-17 17:50\"\n```\n\nagent 据此用 `tiddlywiki_put` 写回(**不传 `tags` 就保留原标签**,只补 `fields`):\n\n```\ntiddlywiki_put(title=\"某篇\", text=<原正文>, fields={\"pub-state\":\"published\", \"pub-platform\":\"wechat\", \"pub-wechat-at\":\"...\"})\n```\n\n> ⚠️ 用工具时 `text` 会被覆盖,**务必带上原正文**。别像我一样用裸 REST PUT 只发一段\n> 文字——那会把笔记正文清空(真实踩过,靠 git 恢复)。\n\n### 存量回填\n\n早于本规范、来源可辨(`source-path` 含「公众号」)的文章,用脚本回填:\n\n```bash\n# 默认 dry-run,只打印将改什么\nnode tools/wechat/backfill-publish-state.mjs\n\n# 确认后真写\nnode tools/wechat/backfill-publish-state.mjs --write\n\n# 指定标题 / 平台 / 匹配子串\nnode tools/wechat/backfill-publish-state.mjs --title \"打工记\" --title \"鱼\" --write\nnode tools/wechat/backfill-publish-state.mjs --match 公众号 --platform wechat --write\n```\n\n脚本纪律(**请勿改成「只写字段」的裸 PUT**):每条都先 GET **完整** tiddler(含\ntext/tags/type/created),**只添加** `pub-*` 字段后整体写回;已有 `pub-state` 的跳过\n(幂等);正文为空的跳过(宁可不动);**发表时间无法考证就留空,不编造**。\n\n---\n\n## 4. ⚠️ 三个必须知道的坑\n\n### 4.1 所有命令都要带 `--trace retain-on-failure`\n\n不带会对 `mp.weixin.qq.com` 稳定报:\n\n```\nNavigation rejected\n```\n\n**实测**:trace 开 → 5/5 成功;trace 关 → 8/8 失败。这是 opencli 1.8.7 的 bug\n(源码里 trace 只做录像,理论上不该影响导航)。已试过无效的绕法:\n`--site-session ephemeral`、`--keep-tab false`、`--window foreground/background`、\n重置标签页、重启 daemon。**唯一有效就是加 trace。**\n\n### 4.2 发表必须管理员扫码(无法自动化)\n\n后台点「发表」后,微信要求**管理员微信扫码确认**。这不是技术能绕开的账号安全机制。\n所以「一键发布」的真实含义是:\n\n> agent/脚本 完成素材、排版、上传、填表、进草稿箱 —— **你只需扫一次码**。\n\n`--publish` 会自动点「发表」并在终端提示你扫码,然后轮询结果(默认等 180s,\n可用 `--timeout` 调整)。\n\n### 4.3 「发表」≠「群发」(别搞混)\n\n| 操作 | 推送粉丝 | 占群发额度 | 需要扫码 |\n|---|---|---|---|\n| **发表**(本方案默认) | 否(仅生成永久链接) | 否,不限次数 | 是 |\n| **群发** | 是 | 个人订阅号 1 天 1 次 | 是 |\n\n两者都要扫码 → 默认选**不吃额度**的「发表」。\n\n### 4.4 发表阶段的确认弹窗 adapter 不处理(v0.23.x 现状)\n\n`publishDraft` 点完「发表」后**只轮询成功信号**,不会点任何平台弹窗。发表链路上\n还有:原创声明/作者/留言设置等**确认对话框**(2026-09-17 人工发表实测必经)与最后\n的管理员扫码。所以**推荐流程到草稿箱为止**(见 §1),发表环节人工做;`--publish`\n只是 best-effort,遇到弹窗会卡到超时。排错见 §8。\n\n---\n\n## 5. 为什么不用官方 API(背景,避免走回头路)\n\n公众号发布的服务端链路是 `access_token → draft/add → freepublish/submit`。但:\n\n- **2025 年 7 月起,官方回收「发布能力」接口对个人主体、企业未认证、不支持认证账号的调用权限**\n ([发布能力文档](https://developers.weixin.qq.com/doc/subscription/guide/product/publish.html) 原文注)。\n 个人主体无法做微信认证 → `freepublish/submit` 不可用。\n- 草稿箱 `draft/add` 文档未列该限制,但 48001 / `api unauthorized` 是常见返回。\n- 即便可用,还叠加:**本机公网出口 IP 必须加入 API IP 白名单**(否则 61004/40164)、\n 封面 `thumb_media_id` 必须是**永久素材**、正文图片必须是 `media/uploadimg` 产出的\n mmbiz 地址(外链图被过滤)。\n\n**而后台网页端从未受影响**——这是所有个人号日常发文的方式。所以本方案驱动网页端。\n\n---\n\n## 6. 文件清单(本方案包含什么)\n\n```\ntools/wechat/\n├── wechat-html.js # 排版装饰器:语义 HTML → 内联样式 HTML(主题表在这里改)\n├── weixin-flow.js # 浏览器流程:登录检测/填表/写正文/传图/设封面/存草稿/发表\n├── create-article.js # 命令:从本地 HTML 文件建草稿(可选发表)\n├── publish-note.js # 命令:从 TW 笔记建草稿(主入口,含 --preview;单图 --cover)\n├── publish-note-imgs.js # 命令:多图版——正文内嵌 data URI 图全部上传 CDN 再发布\n└── install-wechat-adapters.mjs # 一键安装到 ~/.opencli/clis/weixin/\n```\n\n装到本机后对应 `~/.opencli/clis/weixin/` 下的同名文件。\n改排版只需改 `tools/wechat/wechat-html.js` 里的 `ELEMENT_STYLES`,重跑安装脚本即可。\n\n---\n\n## 7. 技术实现要点(改代码前必读)\n\n### 7.1 正文写入必须用 `insertHTML`,不能用 `insertText`\n\n微信编辑器是 **ProseMirror**。`document.execCommand('insertHTML', …)` 保留内联样式;\n`insertText` 会把 HTML/Markdown 当**字面文本**(opencli 内置的 `weixin create-draft`\n就是这样——实测后台 digest 里 `# 一级标题` 和 `**加粗**` 的符号原样保留)。\n\n### 7.2 图片上传必须用 DataTransfer,不能用 `page.setFileInput`\n\n`page.setFileInput` 依赖 CDP 的 `Page.fileChooserOpened` 事件,本机(opencli 1.8.7 +\n扩展 v1.0.24 + Edge)**稳定失败**:\n\n```\nPage.fileChooserOpened not received within 5s\n```\n\n([issue #1582](https://github.com/jackwener/OpenCLI/issues/1582) 显示扩展/CLI 版本不匹配确有历史问题。)\n\n**绕过办法**——在页面上下文用 DataTransfer 直接把 File 塞进 `input.files`:\n\n```js\nconst bin = atob(base64)\nconst arr = new Uint8Array(bin.length)\nfor (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)\nconst file = new File([arr], 'cover.png', { type: 'image/png' })\nconst dt = new DataTransfer()\ndt.items.add(file)\ninput.files = dt.files\ninput.dispatchEvent(new Event('change', { bubbles: true }))\n```\n\n实测图片真的上了 `mmbiz.qpic.cn`。**代价:字节要以 base64 穿过 evaluate → 单图限 8MB。**\n\n### 7.3 微信编辑器会剥 `<style>` 和 class,只认内联 `style`\n\n实测:注入 `<style>h1{color:red}</style>` → `hasStyleTag: false`;\n注入 `style=\"font-size:20px\"` → 原样保留。\n而 **TiddlyWiki 的 `/render` 输出零内联样式**(纯语义 HTML + class)——\n这正是需要 `wechat-html.js` 装饰器的原因。\n\n### 7.4 轮询里绝不能读 `document.body.innerText`(v0.23.0 实测踩坑)\n\n微信编辑器 DOM 极大,读一次 `body.innerText` **强制整页 layout,实测单次 evaluate\n≈17 秒**。早期 `saveDraft` / `publishDraft` 的轮询用它找成功提示,8 次轮询把整个命令\n拖过 210s 超时——**最坑的是草稿其实已经保存成功**,用户看到的是超时失败(假阴性)。\n\n现在只查少量 toast 节点(`.weui-desktop-toast` / `.weui-desktop-msg` / `#js_save_success`)\n和 URL,都是 O(1) 操作。修复后 `saveDraft` 从 **137 秒降到 3.9 秒**。\n\n`scripts/verify-wechat-adapters.mjs` 有回归守门(已反向验证:塞回去即红)。\n\n### 7.5 其他踩过的坑\n\n- 图片下拉必须**按文案**点「本地上传」:用 `items[0]` 点完 file input 仍不可见。\n- 正文是编辑器页**最后一个** `contenteditable`。\n- 回读校验必须**忽略所有空白**:编辑器 innerText 会在块级元素间插空白、把空格转 nbsp,\n 直接用原始字符串比对必假阴性。\n- 保存按钮的文案兜底**特意不含「发表/发布」**,防止误点发布。\n- 草稿列表显示「图文内容不完整」**只表示缺封面**,不代表写入失败。\n- `<pre>` 内的 `<code>` 不能套用行内代码样式(粉底 + 内边距会很难看),\n `wechat-html.js` 里用 `preDepth` 特判。\n\n---\n\n## 8. 排错\n\n| 现象 | 原因 / 处理 |\n|---|---|\n| `Navigation rejected` | 忘了 `--trace retain-on-failure` |\n| `AUTH_REQUIRED` / 提示登录 | 浏览器里 `mp.weixin.qq.com` 登录态过期 → 重新扫码登录 |\n| `Extension: not connected` | 扩展没装/没启用,或浏览器没运行 → 见 §2 |\n| `Page.fileChooserOpened not received` | 不应再出现(已改用 DataTransfer);若出现说明源码被改回 setFileInput |\n| 找不到笔记 | tiddler 标题要**完全精确**(含空格/标点);`opencli weixin publish-note \"标题\"` |\n| `/render 返回 HTTP 404` | 标题不存在;先用 `tiddlywiki_search` 或 TW 面板确认 |\n| 无法连接 DSH | `--dsn` 默认 `http://127.0.0.1:3080/dsh-tiddlywiki`;DSH 没跑或端口不同就改它 |\n| 草稿显示「内容不完整」 | 缺封面 → 传 `--cover`(或用 publish-note-imgs 从正文第一张自动设) |\n| 图片没上传 | 单图 > 8MB(DataTransfer 限制)→ 先压缩 |\n| 发表卡住超时 | 没扫码,或扫码没完成 → 调大 `--timeout`;若卡在原创声明/留言等确认弹窗 → 推荐流程本就到草稿箱为止,到后台人工点发表(见 §1、§4.4) |\n| `stale page identity` | 命令执行中标签页被手工关闭/导航了。**草稿不丢**——重开编辑页续作:`https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&type=77&appmsgid=<id>&idx=0&token=<token>`(token 用 `opencli browser wx eval 'location.href.match(/token=(\\d+)/)[1]'` 从后台首页取) |\n| 封面警告「设置失败」但草稿其实有封面 | 已知**误报**(校验时机太早);以草稿箱实际显示为准(2026-09-17 实测:`list_ex` 接口 `cover` 字段已是 mmbiz 地址)。v0.23.2 起改为轮询校验 |\n| 用 eval 诊断后台状态时读到「未实名」「未设置头像和名称」等提示 | ⚠️ 后台 DOM 里常残留**不可见的历史 toast 节点**——查询必须过滤可见性(`offsetHeight > 0`),勿把残留文案当实时状态(2026-09-17 踩过:据此误判账号被平台拦截,实际账号正常并成功发表含原创声明)。同理,判断原创声明是否生效以草稿箱/发表记录为准,勿只看侧栏文案 |\n| 需要核实草稿是否落盘 | 后台 ajax:`/cgi-bin/appmsg?action=list_ex&type=77&orderby=create_time&token=<token>&f=json&begin=0&count=5`(在 mp.weixin.qq.com 页面上下文执行;`cover`/`digest`/`update_time` 一目了然);发表记录:`/cgi-bin/appmsgpublish?sub=list&...&f=json`(`publish_page.publish_list[].publish_info` 里是 JSON 字符串,含 `content_url`) |\n\n---\n\n## 9. 安全与隐私\n\n- **不存任何公众号凭据**:全程复用浏览器已有登录态(cookie),脚本不接触 AppSecret。\n- **不落盘密钥**:adapter 只读本地图片路径,不写任何 token。\n- **低频使用**:不做规避风控的行为伪装;默认走「发表」(不推送粉丝、不占额度)。\n- wiki 里的配置 tiddler 与本方案无关——本方案零配置。\n\n---\n\n## 10. 参考\n\n- [opencli](https://github.com/jackwener/opencli) — 把网站变成 CLI,复用浏览器登录态\n- [微信「发布能力」文档(2025-07 权限回收)](https://developers.weixin.qq.com/doc/subscription/guide/product/publish.html)\n- [微信「新增草稿」draft/add](https://developers.weixin.qq.com/doc/subscription/api/draftbox/draftmanage/api_draft_add.html)\n- [微信服务端 API 调用说明(IP 白名单)](https://developers.weixin.qq.com/doc/subscription/guide/dev/api/)\n- 设计文档:`docs/plans/2026-09-17-wechat-publish-design.md`\n"
33
33
 
34
34
  /** Write the doc + marker (ONE-SHOT when `force` is false). */
35
35
  export async function seedWechatDocs(client: TiddlyWebClient, opts?: { force?: boolean }): Promise<boolean> {
@@ -145,7 +145,22 @@ export async function writeSessionScopes(state: SessionScopeState, file: string
145
145
  export async function setSessionScope(sessionId: string, wikiId: string | undefined, file: string = defaultSessionScopeFile()): Promise<void> {
146
146
  if (!isSafeSessionId(sessionId)) throw new Error(`会话 id 非法:${JSON.stringify(sessionId)}`)
147
147
  if (wikiId !== undefined && !isSafeWikiId(wikiId)) throw new Error(`知识库 id 非法:${JSON.stringify(wikiId)}`)
148
- const existing = await readFile(file, 'utf8').catch(() => '')
148
+ /**
149
+ * 读失败 ≠ 没有文件(铁律 #3)。
150
+ *
151
+ * Only ENOENT may be treated as "no scope file yet". Any other error
152
+ * (EACCES/EBUSY, a Windows file lock, or losing the race against a concurrent
153
+ * `writeSessionScopes` tmp+rename) used to collapse into `''` — and because the
154
+ * next lines are a read-modify-WRITE of the whole file, that silently dropped
155
+ * every OTHER session's knowledge-base scope. Throwing keeps the file intact
156
+ * and surfaces the failure to the selector instead.
157
+ */
158
+ let existing = ''
159
+ try {
160
+ existing = await readFile(file, 'utf8')
161
+ } catch (err) {
162
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
163
+ }
149
164
  const parsed = existing.length > 0 ? (parseState(existing).state ?? { version: SESSION_SCOPE_VERSION, sessions: {} }) : { version: SESSION_SCOPE_VERSION, sessions: {} }
150
165
  const pruned = pruneScopes(parsed, Date.now())
151
166
  if (wikiId === undefined) delete pruned.sessions[sessionId]
@@ -29,7 +29,7 @@ export function attachTool(env: ToolEnv) {
29
29
  // ── tiddlywiki_attach ────────────────────────────────────────────────────
30
30
  return defineTool({
31
31
  name: 'tiddlywiki_attach',
32
- description: '把一个本机文件或公网 http(s) 地址存成 wiki 的二进制附件 tiddler(图片 / PDF / 压缩包等,type + base64 正文,随 wiki 进 git)。可选把附件嵌入/链接进某篇笔记。这是 agent 唯一能写入二进制附件的途径。',
32
+ description: '把一个本机文件或公网 http(s) 地址存成 wiki 的二进制附件 tiddler(图片 / PDF / 压缩包等,type + base64 正文,随 wiki 进 git)。可选把附件嵌入/链接进某篇笔记。**同名已有条目默认拒绝写入**(避免静默覆盖既有笔记;确认要覆盖才传 `force: true`,tags 与自定义字段仍会保留)。这是 agent 唯一能写入二进制附件的途径。',
33
33
  parameters: {
34
34
  title: { type: 'string', description: '附件 tiddler 标题(同时决定其在 wiki 里的名字)', required: true },
35
35
  path: { type: 'string', description: '本机绝对路径(与 url 二选一)' },
@@ -101,20 +101,65 @@ export function wikiIdFromRequest(req: { url?: string | undefined }): string | u
101
101
  /**
102
102
  * Which runtime does this request target?
103
103
  *
104
- * `?wiki=<id>` naming a RUNNING wiki → that one
105
- * anything else (no selector, unknown id, wiki not running)
106
- * → the farm's default runtime
104
+ * `?wiki=<id>` naming a RUNNING wiki → that one
105
+ * `?wiki=<id>` naming a REGISTERED wiki that is STOPPED → UNDEFINED (refuse)
106
+ * no selector, or an UNKNOWN id → the farm's default
107
107
  *
108
- * An unknown id falls back instead of 404ing on purpose: the selector comes
109
- * from links the user may have bookmarked, and a stale one must not strand them
110
- * on a broken editor. It also means `?wiki=` can never be used to reach a wiki
111
- * that is not running — starting one is an explicit action, not a side effect
112
- * of a GET.
108
+ * TWO FALLBACKS, TWO DIFFERENT REASONS (v0.29.0)
109
+ * ----------------------------------------------
110
+ * An UNKNOWN id falls back instead of 404ing on purpose: the selector comes from
111
+ * links the user may have bookmarked, and a stale one must not strand them on a
112
+ * broken editor. It also means `?wiki=` can never be used to START a wiki —
113
+ * starting one is an explicit action, not a side effect of a GET.
114
+ *
115
+ * A REGISTERED but not-running id must NOT fall back. It used to, and the
116
+ * failure was silent and destructive: the settings page ("正在配置:books")
117
+ * wrote the books patch into the DEFAULT wiki's config tiddler, the quick-note
118
+ * card listed stopped wikis and then saved into the default one, and
119
+ * `/admin/info?wiki=<stopped>` rewrote the default wiki's `tiddlywiki.info`.
120
+ * The named wiki is unambiguously known here, so there is nothing to guess:
121
+ * callers get `undefined` and answer 503 with the wiki named (see
122
+ * `stoppedWikiFromRequest` for the message). This is the same answer the
123
+ * `/tw/<id>/` proxy already gave (it resolves LAST, exactly so a named-but-
124
+ * stopped wiki cannot be proxied to whoever happens to be the default).
113
125
  */
114
126
  export function targetRuntimeFor<T extends WikiRuntime>(farm: WikiFarm<T> | undefined, req: { url?: string | undefined }): T | undefined {
127
+ if (farm === undefined) return undefined
128
+ const id = wikiIdFromRequest(req)
129
+ if (id === undefined) return farm.defaultRuntime()
130
+ const runtime = farm.runtime(id)
131
+ if (runtime !== undefined) return runtime
132
+ // Registered but stopped → refuse (never act on a different knowledge base).
133
+ if (farm.registry.wikis.some((entry) => entry.id === id)) return undefined
134
+ return farm.defaultRuntime()
135
+ }
136
+
137
+ /**
138
+ * The REGISTERED wiki a request names which is not running — the reason a
139
+ * request-scoped target resolved to `undefined` (v0.29.0).
140
+ *
141
+ * Exists so every 503 can NAME the knowledge base and tell the user what to do,
142
+ * instead of the generic "wiki service is not running" that made the old silent
143
+ * fallback look like a success. Returns `undefined` when the request names no
144
+ * wiki, names a running one, or names an unknown id (that last case still falls
145
+ * back to the default by design — see `targetRuntimeFor`).
146
+ */
147
+ export function stoppedWikiFromRequest<T extends WikiRuntime>(farm: WikiFarm<T> | undefined, req: { url?: string | undefined }): WikiEntry | undefined {
148
+ if (farm === undefined) return undefined
115
149
  const id = wikiIdFromRequest(req)
116
- if (id === undefined) return farm?.defaultRuntime()
117
- return farm?.runtime(id) ?? farm?.defaultRuntime()
150
+ if (id === undefined) return undefined
151
+ if (farm.runtime(id) !== undefined) return undefined
152
+ return farm.registry.wikis.find((entry) => entry.id === id)
153
+ }
154
+
155
+ /**
156
+ * The sentence every refusal carries (v0.29.0).
157
+ *
158
+ * Exported so the host wiring and the verification harness cannot drift apart on
159
+ * what a user reads — the guard asserts this text appears in the 503 body.
160
+ */
161
+ export function stoppedWikiMessage(entry: WikiEntry): string {
162
+ return `知识库「${entry.label}」当前没有运行:先在「知识库」菜单里启动它再试`
118
163
  }
119
164
 
120
165
  /**
@@ -482,11 +482,18 @@ export class WikiInstance {
482
482
  // stale snapshot loses every write still queued (v0.19.0).
483
483
  const drained = await flushPendingWrites(seedClient, this.tiddlersDir)
484
484
  if (!drained) this.log('seed writes may not have been flushed before restart')
485
- if (!this.disposed) await this.server.restart()
485
+ // v0.29.0: `this.restart()` (drainThenStop) rather than a bare
486
+ // `server.restart()` — see the pluginAdded branch below for why the boot
487
+ // path may no longer contain a single un-drained restart.
488
+ if (!this.disposed) await this.restart()
486
489
  } else if (pluginAdded) {
487
490
  // tiddlywiki.info is written directly by us (no TW flush to wait for),
488
- // but TW only loads the plugin at boot.
489
- if (!this.disposed) await this.server.restart()
491
+ // but TW only loads the plugin at boot. The SEEDS that just ran DID go
492
+ // through TW's REST API, though, so their writes may still sit in the
493
+ // syncer queue — restart via `this.restart()`, which drains first
494
+ // (v0.29.0: this used to be a bare `server.restart()`, i.e. the one
495
+ // restart path outside `drainThenStop()` that rule #1 forbids).
496
+ if (!this.disposed) await this.restart()
490
497
  }
491
498
  // Apply the configured UI language (e.g. "zh-Hans"): enable the bundled
492
499
  // language plugin in tiddlywiki.info.languages + restart once so TW loads
@@ -496,7 +503,9 @@ export class WikiInstance {
496
503
  try {
497
504
  const code = uiLang.trim()
498
505
  const changed = await ensureLanguage(this.wikiPath, this.options.twRoot(), code)
499
- if (changed && !this.disposed) await this.server.restart()
506
+ // Same drain rule as above (v0.29.0): enabling a language plugin means a
507
+ // restart, and a restart with a non-empty syncer queue loses writes.
508
+ if (changed && !this.disposed) await this.restart()
500
509
  // Pin the active language tiddler so TW's UI actually switches.
501
510
  // v0.24.2: conditional — an identical body still rewrites
502
511
  // `$__language.txt.meta`'s created/modified on EVERY startup, and two
@@ -526,7 +535,11 @@ export class WikiInstance {
526
535
  try {
527
536
  await this.server.start()
528
537
  if (this.disposed) {
529
- await this.server.stop().catch(() => undefined)
538
+ // Route the abort through drainStop() too (v0.29.0): the child is fresh
539
+ // here so there is nothing meaningful to flush, but keeping a SINGLE
540
+ // stop path is what lets the guard script assert "no bare stop/restart
541
+ // anywhere in this file" instead of maintaining an allow-list.
542
+ await this.drainStop().catch(() => undefined)
530
543
  return
531
544
  }
532
545
  await this.config.load(this.client())
package/src/index.ts CHANGED
@@ -36,7 +36,7 @@ import {
36
36
  type WikiLocation,
37
37
  } from './host/wiki-location.ts'
38
38
  import { WikiInstance } from './host/wiki-instance.ts'
39
- import { WikiFarm, resolveAgentScope, targetRuntimeFor } from './host/wiki-farm.ts'
39
+ import { WikiFarm, resolveAgentScope, stoppedWikiFromRequest, stoppedWikiMessage, targetRuntimeFor } from './host/wiki-farm.ts'
40
40
  import { defaultSessionScopeFile, isSafeSessionId, readSessionScopes, setSessionScope } from './host/session-scope.ts'
41
41
  import { installSplitSkill } from './host/skill-install.ts'
42
42
  import {
@@ -201,7 +201,7 @@ export {
201
201
  } from './host/wiki-registry.ts'
202
202
  export { WikiInstance, type WikiInstanceBase, type WikiInstanceOptions } from './host/wiki-instance.ts'
203
203
  export { isInsidePath, pathComparisonKey } from './host/path-key.ts'
204
- export { WikiFarm, targetRuntimeFor, wikiIdFromRequest, resolveAgentScope, type AgentScope, type FarmChange, type WikiFarmOptions, type WikiRuntime } from './host/wiki-farm.ts'
204
+ export { WikiFarm, targetRuntimeFor, stoppedWikiFromRequest, stoppedWikiMessage, wikiIdFromRequest, resolveAgentScope, type AgentScope, type FarmChange, type WikiFarmOptions, type WikiRuntime } from './host/wiki-farm.ts'
205
205
  export {
206
206
  SESSION_SCOPE_MAX_AGE_MS,
207
207
  SESSION_SCOPE_VERSION,
@@ -771,6 +771,19 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
771
771
  * selector (and an unknown id falls back instead of 404ing).
772
772
  */
773
773
  const target = (req: IncomingMessage): WikiInstance | undefined => targetRuntimeFor(farm, req)
774
+ /**
775
+ * Why this request cannot be served (v0.29.0): set when `?wiki=<id>` names a
776
+ * REGISTERED but STOPPED knowledge base. `targetRuntimeFor` deliberately
777
+ * returns undefined for that case instead of falling back to the default wiki
778
+ * (an unknown id still falls back — a stale bookmark must not 404), so every
779
+ * route that either 503s on a missing client or has a harmless-looking
780
+ * fallback (config / wiki path / prompt / status) can say WHICH wiki is down
781
+ * and what to do, instead of quietly acting on a different one.
782
+ */
783
+ const targetProblem = (req: IncomingMessage): string | undefined => {
784
+ const entry = stoppedWikiFromRequest(farm, req)
785
+ return entry === undefined ? undefined : stoppedWikiMessage(entry)
786
+ }
774
787
  const fallbackUi = WikiInstance.uiDefaultsFrom(config)
775
788
  const fallbackWechat = normalizeWechatConfig(config.wechat)
776
789
  /** Used only while nothing runs: base defaults, no wiki tiddler to read. */
@@ -811,6 +824,7 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
811
824
  noteDefaults: (req) => ({ tag: target(req)?.noteTag() ?? config.note.tag }),
812
825
  uiDefaults: (req) => target(req)?.uiDefaults() ?? fallbackUi,
813
826
  getWikiPath: (req) => target(req)?.path ?? defaultPath(),
827
+ targetProblem,
814
828
  // Same helper as the agent tool (one implementation, two callers): a pull
815
829
  // can change several knowledge bases that share one repository.
816
830
  restartAffected: (_req, dir, changedFiles) => restartAffectedWikis({ farm: () => farm, repoRootOf: (d) => repos.repoRootOf(d) }, dir, changedFiles),
@@ -827,6 +841,11 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
827
841
  ...(scopeId !== undefined ? { scope: scopeId } : {}),
828
842
  ...(resolution.entry !== undefined ? { resolved: { id: resolution.entry.id, label: resolution.entry.label } } : {}),
829
843
  ...(resolution.reason !== undefined ? { reason: resolution.reason } : {}),
844
+ // v0.29.0: the client needs to know whether "resolved" is a real
845
+ // answer (multi: this session's tools act on THAT wiki, so its cards'
846
+ // links must open it) or just the single install's only wiki (keep
847
+ // the pre-multi DOM, which renders no wiki attribute at all).
848
+ mode: farm?.registry.mode ?? 'single',
830
849
  }
831
850
  },
832
851
  set: async (sessionId: string, wikiId: string | undefined) => {
@@ -870,6 +889,7 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
870
889
  server: (req) => target(req)?.server,
871
890
  getClient: (req) => target(req)?.client(),
872
891
  getWikiPath: (req) => target(req)?.path ?? defaultPath(),
892
+ targetProblem,
873
893
  twRoot: resolveTwRoot,
874
894
  config: (req) => target(req)?.config ?? idleConfig,
875
895
  // A settings-page save may change prompt.*: re-register the section (no