dsh-tiddlywiki 0.19.2 → 0.19.4

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/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { existsSync, readFileSync, readdirSync, statSync, watch } from "node:fs";
3
- import { access, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
3
+ import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
5
5
  import { execFile, spawn } from "node:child_process";
6
6
  import { promisify } from "node:util";
@@ -836,8 +836,18 @@ var TiddlyWebClient = class TiddlyWebClient {
836
836
  /**
837
837
  * Distinct non-system tags with their tiddler counts, most-used first then
838
838
  * zh-locale. One skinny listing request (no text payloads).
839
+ *
840
+ * `limit` (v0.19.4) caps the returned array after sorting; the caller loses
841
+ * the tail count unless it also asks `tagStats()`.
839
842
  */
840
- async listTags() {
843
+ async listTags(limit) {
844
+ const tags = (await this.tagStats()).tags;
845
+ if (limit === void 0) return tags;
846
+ return tags.slice(0, Math.max(1, Math.min(Math.floor(limit), 1e3)));
847
+ }
848
+ /** Full tag vocabulary + its size, so callers that cap can still report the
849
+ * total ("showing 200 of N") — one listing request either way. */
850
+ async tagStats() {
841
851
  const items = await this.list(TEXT_LIST_FILTER, false);
842
852
  const map = /* @__PURE__ */ new Map();
843
853
  for (const t of items) {
@@ -847,10 +857,14 @@ var TiddlyWebClient = class TiddlyWebClient {
847
857
  map.set(tag, (map.get(tag) ?? 0) + 1);
848
858
  }
849
859
  }
850
- return [...map.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], "zh")).map(([tag, count]) => ({
860
+ const tags = [...map.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], "zh")).map(([tag, count]) => ({
851
861
  tag,
852
862
  count
853
863
  }));
864
+ return {
865
+ total: tags.length,
866
+ tags
867
+ };
854
868
  }
855
869
  };
856
870
  /** Clamp a caller-supplied limit into 1…200 (default when absent/invalid). */
@@ -1315,7 +1329,7 @@ function scanSnapshot(snap, subagent, collected) {
1315
1329
  title: args.newTitle,
1316
1330
  action: "produced",
1317
1331
  time: t,
1318
- detail: old !== void 0 ? `重命名自「${old}」` : void 0,
1332
+ detail: old !== void 0 && isPlausibleTitle(old) ? `重命名自「${old}」` : void 0,
1319
1333
  subagent
1320
1334
  });
1321
1335
  }
@@ -1362,6 +1376,14 @@ function scanSnapshot(snap, subagent, collected) {
1362
1376
  }
1363
1377
  }
1364
1378
  }
1379
+ /**
1380
+ * 内联文本净化(v0.19.3):汇总 wikitext 会被 TW 渲染成 HTML 片段再注入 DSH 页面,
1381
+ * 而 TW 的解析器**原样透传 HTML**。凡是来自会话日志/请求体的字符串(sessionId、
1382
+ * rename 的旧标题、检索词)都要先转义 HTML 元字符并去掉控制字符,别指望上层净化。
1383
+ */
1384
+ function escapeInline(text, max = 200) {
1385
+ return text.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/[[\]|]/g, " ").slice(0, max);
1386
+ }
1365
1387
  /** 并发(8 路)查询每篇笔记的当前状态(404 → 不存在/已删除)。 */
1366
1388
  async function enrichTitles(client, titles) {
1367
1389
  const out = /* @__PURE__ */ new Map();
@@ -1382,7 +1404,8 @@ async function enrichTitles(client, titles) {
1382
1404
  } catch {
1383
1405
  return [title, {
1384
1406
  exists: false,
1385
- tags: []
1407
+ tags: [],
1408
+ unknown: true
1386
1409
  }];
1387
1410
  }
1388
1411
  };
@@ -1410,7 +1433,7 @@ function buildWikitext(sessionId, collected, producedTitles, readTitles, stateBy
1410
1433
  const lines = [];
1411
1434
  lines.push("! 会话相关 wiki 汇总");
1412
1435
  lines.push("");
1413
- lines.push(`本页列出会话 \`${sessionId}\`(含其后代子代理)在本会话中产生 / 读取 / 检索过的知识库笔记。`);
1436
+ lines.push(`本页列出会话 \`${escapeInline(sessionId, 120)}\`(含其后代子代理)在本会话中产生 / 读取 / 检索过的知识库笔记。`);
1414
1437
  lines.push("");
1415
1438
  const producedCount = producedTitles.length;
1416
1439
  const readCount = readTitles.length;
@@ -1426,15 +1449,16 @@ function buildWikitext(sessionId, collected, producedTitles, readTitles, stateBy
1426
1449
  lines.push("");
1427
1450
  const entryLine = (title, state, entry) => {
1428
1451
  const bits = [`[[${title}]]`];
1429
- if (state === void 0 || !state.exists) bits.push("⚠️ 已删除/不存在");
1452
+ if (state !== void 0 && state.unknown === true) bits.push("⚠️ 状态未知(查询失败)");
1453
+ else if (state === void 0 || !state.exists) bits.push("⚠️ 已删除/不存在");
1430
1454
  else {
1431
- if (state.tags.length > 0) bits.push(`标签 ${state.tags.slice(0, 6).join("、")}${state.tags.length > 6 ? "…" : ""}`);
1455
+ if (state.tags.length > 0) bits.push(`标签 ${state.tags.slice(0, 6).map((tag) => escapeInline(tag, 40)).join("、")}${state.tags.length > 6 ? "…" : ""}`);
1432
1456
  const mod = fmtModified(state.modified);
1433
1457
  if (mod.length > 0) bits.push(`修改 ${mod}`);
1434
1458
  }
1435
1459
  const t = fmtTime(entry.time);
1436
1460
  if (t.length > 0) bits.push(`会话内 ${t}`);
1437
- if (entry.detail !== void 0) bits.push(entry.detail);
1461
+ if (entry.detail !== void 0) bits.push(escapeInline(entry.detail, 200));
1438
1462
  if (entry.subagent) bits.push("(子代理)");
1439
1463
  return `* ${bits.join(" · ")}`;
1440
1464
  };
@@ -1459,8 +1483,8 @@ function buildWikitext(sessionId, collected, producedTitles, readTitles, stateBy
1459
1483
  for (const r of collected.searches) {
1460
1484
  const bits = [];
1461
1485
  if (r.kind === "search") {
1462
- bits.push(`\`tiddlywiki_search\` query=「${r.query.length > 0 ? r.query : "(空)"}」`);
1463
- if (r.tags.length > 0) bits.push(`tags ${r.tags.join("、")}`);
1486
+ bits.push(`\`tiddlywiki_search\` query=「${r.query.length > 0 ? escapeInline(r.query, 120) : "(空)"}」`);
1487
+ if (r.tags.length > 0) bits.push(`tags ${r.tags.map((tag) => escapeInline(tag, 40)).join("、")}`);
1464
1488
  } else bits.push("`tiddlywiki_recent`(最近修改)");
1465
1489
  const t = fmtTime(r.time);
1466
1490
  if (t.length > 0) bits.push(t);
@@ -1575,6 +1599,39 @@ function json(res, payload, status = 200) {
1575
1599
  res.end(JSON.stringify(payload));
1576
1600
  }
1577
1601
  /**
1602
+ * Map a thrown error to an HTTP status. Oversized bodies are a client problem
1603
+ * (413), everything else is ours (500) — the routes used to answer 500 for
1604
+ * `/note` and `/admin/*` but 413 for `/upload` for the identical condition.
1605
+ */
1606
+ function errorStatus(err) {
1607
+ const message = err instanceof Error ? err.message : String(err);
1608
+ return /body too large/i.test(message) ? 413 : 500;
1609
+ }
1610
+ /**
1611
+ * Wrap an async route handler so a rejected promise can never become an
1612
+ * unhandled rejection (v0.19.3).
1613
+ *
1614
+ * WHY: every registration used to be `void handleX(req, res)`, and the host
1615
+ * installs no `unhandledRejection` handler — Node's default policy then EXITS
1616
+ * the whole dsh web process, while the client request hangs with no response.
1617
+ * A handler that throws (including a sync throw before its own try block, e.g.
1618
+ * `new URL(req.url)` outside the try in the proxies) now always ends in a
1619
+ * response: 413/500 JSON, or a bare `end()` when headers are already sent.
1620
+ */
1621
+ function guardHandler(handler) {
1622
+ return (req, res) => {
1623
+ handler(req, res).catch((err) => {
1624
+ try {
1625
+ if (!res.headersSent) json(res, {
1626
+ ok: false,
1627
+ error: err instanceof Error ? err.message : String(err)
1628
+ }, errorStatus(err));
1629
+ else res.end();
1630
+ } catch {}
1631
+ });
1632
+ };
1633
+ }
1634
+ /**
1578
1635
  * True when a NON-GET/HEAD/OPTIONS request is a cross-site (CSRF) request.
1579
1636
  *
1580
1637
  * Every legitimate caller of these routes is same-origin: the DSH GUI, the
@@ -3667,6 +3724,8 @@ function sanitizeUploadName(input) {
3667
3724
  if (typeof input !== "string") return "";
3668
3725
  let name = basename(input.trim().replace(/[\\/]+/g, "/")).replace(/[\u0000-\u001f\u007f]/g, "").replace(/[<>:"|?*]/g, "_").replace(/^\.+/, "").trim();
3669
3726
  if (name.length === 0 || name === "." || name === "..") return "";
3727
+ name = name.replace(/[. ]+$/, "");
3728
+ if (name.length === 0) return "";
3670
3729
  if (WINDOWS_RESERVED_NAMES.test(name)) name = `_${name}`;
3671
3730
  if (name.length > 160) name = name.slice(0, 160);
3672
3731
  return name;
@@ -3702,6 +3761,24 @@ function snippetOf$1(text, max = 120) {
3702
3761
  const flat = text.replace(/\s+/g, " ").trim();
3703
3762
  return flat.length <= max ? flat : `${flat.slice(0, max)}…`;
3704
3763
  }
3764
+ /** Max `limit` accepted by the list routes (bounded payloads, v0.19.4). */
3765
+ const MAX_LIST_LIMIT = 200;
3766
+ /** Max `limit` accepted by `/tags` — the tag list is a small wrapper around one
3767
+ * full listing, so a larger cap is fine while still bounding the payload. */
3768
+ const MAX_TAGS_LIMIT = 500;
3769
+ /** Clamp a `limit` query param. `fallback` covers absent/unparsable values. */
3770
+ function readLimit(url, fallback, max = MAX_LIST_LIMIT) {
3771
+ const raw = Number(url.searchParams.get("limit") ?? fallback);
3772
+ return Number.isFinite(raw) ? Math.max(1, Math.min(Math.floor(raw), max)) : fallback;
3773
+ }
3774
+ /** Optional `limit` query param: `undefined` when absent/unparsable (= no cap). */
3775
+ function readOptionalLimit(url, max) {
3776
+ const raw = url.searchParams.get("limit");
3777
+ if (raw === null || raw.trim().length === 0) return void 0;
3778
+ const parsed = Number(raw);
3779
+ if (!Number.isFinite(parsed)) return void 0;
3780
+ return Math.max(1, Math.min(Math.floor(parsed), max));
3781
+ }
3705
3782
  /** Default note title: `YYYY-MM-DD HH:mm` (design doc D6). */
3706
3783
  function timestampTitle(date = /* @__PURE__ */ new Date()) {
3707
3784
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
@@ -3868,6 +3945,13 @@ function registerRoutes(ctx, deps) {
3868
3945
  }, 400);
3869
3946
  return;
3870
3947
  }
3948
+ if (!/^[A-Za-z0-9._:-]{1,120}$/.test(session)) {
3949
+ json(res, {
3950
+ ok: false,
3951
+ error: "session id has an unsupported format"
3952
+ }, 400);
3953
+ return;
3954
+ }
3871
3955
  const client = deps.getClient();
3872
3956
  if (client === void 0) {
3873
3957
  json(res, {
@@ -3893,7 +3977,7 @@ function registerRoutes(ctx, deps) {
3893
3977
  json(res, {
3894
3978
  ok: false,
3895
3979
  error: err instanceof Error ? err.message : String(err)
3896
- }, 500);
3980
+ }, errorStatus(err));
3897
3981
  }
3898
3982
  };
3899
3983
  /**
@@ -3937,7 +4021,7 @@ function registerRoutes(ctx, deps) {
3937
4021
  json(res, {
3938
4022
  ok: false,
3939
4023
  error: err instanceof Error ? err.message : String(err)
3940
- }, 500);
4024
+ }, errorStatus(err));
3941
4025
  }
3942
4026
  };
3943
4027
  /**
@@ -4015,7 +4099,7 @@ function registerRoutes(ctx, deps) {
4015
4099
  json(res, {
4016
4100
  ok: false,
4017
4101
  error: err instanceof Error ? err.message : String(err)
4018
- }, 500);
4102
+ }, errorStatus(err));
4019
4103
  }
4020
4104
  };
4021
4105
  /**
@@ -4073,7 +4157,7 @@ function registerRoutes(ctx, deps) {
4073
4157
  json(res, {
4074
4158
  ok: false,
4075
4159
  error: err instanceof Error ? err.message : String(err)
4076
- }, 500);
4160
+ }, errorStatus(err));
4077
4161
  }
4078
4162
  };
4079
4163
  /**
@@ -4129,6 +4213,32 @@ function registerRoutes(ctx, deps) {
4129
4213
  }, 503);
4130
4214
  return;
4131
4215
  }
4216
+ if (mode !== void 0) {
4217
+ const ap = deps.getAgentPresets();
4218
+ if (ap === void 0) {
4219
+ json(res, {
4220
+ ok: false,
4221
+ error: "agent presets service unavailable"
4222
+ }, 503);
4223
+ return;
4224
+ }
4225
+ try {
4226
+ const presets = await ap.list();
4227
+ if (!presets.some((preset) => preset.id === mode)) {
4228
+ json(res, {
4229
+ ok: false,
4230
+ error: `unknown agent preset "${mode}" (available: ${presets.map((preset) => preset.id).join(", ")})`
4231
+ }, 400);
4232
+ return;
4233
+ }
4234
+ } catch (err) {
4235
+ json(res, {
4236
+ ok: false,
4237
+ error: `cannot validate agent preset: ${err instanceof Error ? err.message : String(err)}`
4238
+ }, 503);
4239
+ return;
4240
+ }
4241
+ }
4132
4242
  const ws = deps.getWorkspaceRegistry();
4133
4243
  if (cwd.length > 0) {
4134
4244
  if (!isAbsolute(cwd)) {
@@ -4186,7 +4296,7 @@ function registerRoutes(ctx, deps) {
4186
4296
  json(res, {
4187
4297
  ok: false,
4188
4298
  error: err instanceof Error ? err.message : String(err)
4189
- }, 500);
4299
+ }, errorStatus(err));
4190
4300
  }
4191
4301
  };
4192
4302
  const handleNote = async (req, res) => {
@@ -4250,7 +4360,7 @@ function registerRoutes(ctx, deps) {
4250
4360
  json(res, {
4251
4361
  ok: false,
4252
4362
  error: err instanceof Error ? err.message : String(err)
4253
- }, 500);
4363
+ }, errorStatus(err));
4254
4364
  }
4255
4365
  };
4256
4366
  const handleEdit = async (req, res) => {
@@ -4296,13 +4406,18 @@ function registerRoutes(ctx, deps) {
4296
4406
  json(res, {
4297
4407
  ok: false,
4298
4408
  error: err instanceof Error ? err.message : String(err)
4299
- }, 500);
4409
+ }, errorStatus(err));
4300
4410
  }
4301
4411
  };
4302
4412
  /** Distinct non-system tags for the quick-note tag autocomplete. The flat
4303
4413
  * `tags` array feeds note-widget's chip autocomplete; the parallel `items`
4304
4414
  * (tag → tiddler count) feeds the reply-stream `tiddlywiki_list_tags` tool
4305
- * card, so both consumers share one endpoint. */
4415
+ * card, so both consumers share one endpoint.
4416
+ *
4417
+ * Query params (v0.19.4): `limit` caps the payload (absent = every tag, so
4418
+ * the autocomplete keeps its full vocabulary), `sort=count` orders by usage
4419
+ * (default `alpha`). `tags`/`items` are always the same set in the same
4420
+ * order, plus `total`/`truncated` so a capped caller can say "N of M". */
4306
4421
  const handleTags = async (req, res) => {
4307
4422
  if (rejectNonRead(req, res)) return;
4308
4423
  const client = deps.getClient();
@@ -4314,23 +4429,24 @@ function registerRoutes(ctx, deps) {
4314
4429
  return;
4315
4430
  }
4316
4431
  try {
4317
- const items = await client.list(TEXT_LIST_FILTER, false);
4318
- const counts = /* @__PURE__ */ new Map();
4319
- for (const item of items) for (const tag of item.tags ?? []) if (tag.length > 0 && !tag.startsWith("$:/")) counts.set(tag, (counts.get(tag) ?? 0) + 1);
4320
- const tags = [...counts.keys()].sort((a, b) => a.localeCompare(b, "zh"));
4432
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
4433
+ const limit = readOptionalLimit(url, MAX_TAGS_LIMIT);
4434
+ const byCount = (url.searchParams.get("sort") ?? "alpha") === "count";
4435
+ const { total, tags: all } = await client.tagStats();
4436
+ const ordered = byCount ? all : [...all].sort((a, b) => a.tag.localeCompare(b.tag, "zh"));
4437
+ const picked = limit === void 0 ? ordered : ordered.slice(0, limit);
4321
4438
  json(res, {
4322
4439
  ok: true,
4323
- tags,
4324
- items: tags.map((tag) => ({
4325
- tag,
4326
- count: counts.get(tag) ?? 0
4327
- }))
4440
+ tags: picked.map((t) => t.tag),
4441
+ items: picked,
4442
+ total,
4443
+ truncated: picked.length < total
4328
4444
  });
4329
4445
  } catch (err) {
4330
4446
  json(res, {
4331
4447
  ok: false,
4332
4448
  error: err instanceof Error ? err.message : String(err)
4333
- }, 500);
4449
+ }, errorStatus(err));
4334
4450
  }
4335
4451
  };
4336
4452
  /** Recent non-system tiddlers for the quick-note "最近" picker (newest first). */
@@ -4345,9 +4461,7 @@ function registerRoutes(ctx, deps) {
4345
4461
  return;
4346
4462
  }
4347
4463
  try {
4348
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
4349
- const limitRaw = Number(url.searchParams.get("limit") ?? 15);
4350
- const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(Math.floor(limitRaw), 200)) : 15;
4464
+ const limit = readLimit(new URL(req.url ?? "/", "http://127.0.0.1"), 15);
4351
4465
  json(res, {
4352
4466
  ok: true,
4353
4467
  limit,
@@ -4362,7 +4476,7 @@ function registerRoutes(ctx, deps) {
4362
4476
  json(res, {
4363
4477
  ok: false,
4364
4478
  error: err instanceof Error ? err.message : String(err)
4365
- }, 500);
4479
+ }, errorStatus(err));
4366
4480
  }
4367
4481
  };
4368
4482
  /** Full tiddler for the quick-note "最近" picker (load into the editor). */
@@ -4418,7 +4532,7 @@ function registerRoutes(ctx, deps) {
4418
4532
  json(res, {
4419
4533
  ok: false,
4420
4534
  error: err instanceof Error ? err.message : String(err)
4421
- }, 500);
4535
+ }, errorStatus(err));
4422
4536
  }
4423
4537
  };
4424
4538
  /**
@@ -4444,8 +4558,7 @@ function registerRoutes(ctx, deps) {
4444
4558
  const tag = url.searchParams.get("tag") ?? void 0;
4445
4559
  const since = url.searchParams.get("since") ?? void 0;
4446
4560
  const type = url.searchParams.get("type") ?? void 0;
4447
- const limitRaw = Number(url.searchParams.get("limit") ?? 30);
4448
- const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(Math.floor(limitRaw), 200)) : 30;
4561
+ const limit = readLimit(url, 30);
4449
4562
  const { items, total } = await client.search(query, {
4450
4563
  tags,
4451
4564
  tag,
@@ -4473,7 +4586,7 @@ function registerRoutes(ctx, deps) {
4473
4586
  json(res, {
4474
4587
  ok: false,
4475
4588
  error: err instanceof Error ? err.message : String(err)
4476
- }, 500);
4589
+ }, errorStatus(err));
4477
4590
  }
4478
4591
  };
4479
4592
  const handleRestart = async (req, res) => {
@@ -4499,7 +4612,7 @@ function registerRoutes(ctx, deps) {
4499
4612
  json(res, {
4500
4613
  ok: false,
4501
4614
  error: err instanceof Error ? err.message : String(err)
4502
- }, 500);
4615
+ }, errorStatus(err));
4503
4616
  }
4504
4617
  };
4505
4618
  /** One-click git sync for the floating button / settings page: pull →
@@ -4568,7 +4681,7 @@ function registerRoutes(ctx, deps) {
4568
4681
  json(res, {
4569
4682
  ok: false,
4570
4683
  error: err instanceof Error ? err.message : String(err)
4571
- }, 500);
4684
+ }, errorStatus(err));
4572
4685
  } finally {
4573
4686
  endMutation();
4574
4687
  }
@@ -4612,16 +4725,24 @@ function registerRoutes(ctx, deps) {
4612
4725
  await mkdir(filesDir, { recursive: true });
4613
4726
  const ext = extname(name);
4614
4727
  const stem = ext.length > 0 ? name.slice(0, -ext.length) : name;
4615
- let candidate = name;
4616
- for (let i = 1; i <= 1e4; i++) {
4728
+ let candidate = "";
4729
+ let wrote = false;
4730
+ for (let i = 0; i <= 1e4 && !wrote; i++) {
4731
+ candidate = i === 0 ? name : `${stem}-${i}${ext}`;
4617
4732
  try {
4618
- await access(join(filesDir, candidate));
4619
- } catch {
4620
- break;
4733
+ await writeFile(join(filesDir, candidate), buf, { flag: "wx" });
4734
+ wrote = true;
4735
+ } catch (err) {
4736
+ if (err.code !== "EEXIST") throw err;
4621
4737
  }
4622
- candidate = `${stem}-${i}${ext}`;
4623
4738
  }
4624
- await writeFile(join(filesDir, candidate), buf);
4739
+ if (!wrote) {
4740
+ json(res, {
4741
+ ok: false,
4742
+ error: "同名文件过多,请换一个文件名"
4743
+ }, 409);
4744
+ return;
4745
+ }
4625
4746
  deps.autoCommit();
4626
4747
  invalidateGitStatus();
4627
4748
  json(res, {
@@ -4714,6 +4835,29 @@ function registerRoutes(ctx, deps) {
4714
4835
  }, 502);
4715
4836
  }
4716
4837
  };
4838
+ /**
4839
+ * Titles the browser-facing TW proxies must never serve (v0.19.3).
4840
+ *
4841
+ * `$:/plugins/dsh-tiddlywiki/config` holds the shared tokens and the git
4842
+ * remote (possibly with a PAT); both `/tw` and `/api` forward ANY path to the
4843
+ * loopback TW child, whose TiddlyWeb REST answers for `$:/…` titles — a
4844
+ * route-level guard on `/get` was therefore trivially bypassed by
4845
+ * `GET /dsh-tiddlywiki/tw/recipes/default/tiddlers/%24%3A%2Fplugins%2F…`
4846
+ * (verified). The whole plugin namespace is blocked: nothing under it is
4847
+ * needed by the TW frontend, and the host itself talks to TW directly.
4848
+ */
4849
+ const BLOCKED_PROXY_TITLE_PREFIXES = ["$:/plugins/dsh-tiddlywiki/"];
4850
+ /** True when a proxied pathname addresses a blocked (secret-bearing) tiddler. */
4851
+ const isBlockedProxyPath = (pathname) => {
4852
+ const at = pathname.indexOf("/tiddlers/");
4853
+ if (at < 0) return false;
4854
+ const raw = pathname.slice(at + 10);
4855
+ let title = raw;
4856
+ try {
4857
+ title = decodeURIComponent(raw);
4858
+ } catch {}
4859
+ return BLOCKED_PROXY_TITLE_PREFIXES.some((prefix) => title.startsWith(prefix));
4860
+ };
4717
4861
  /** Passthrough /dsh-tiddlywiki/api/<rest> → TW root /<rest>. */
4718
4862
  const handleApiProxy = async (req, res) => {
4719
4863
  if (rejectCrossSiteWrite(req, res)) return;
@@ -4724,9 +4868,16 @@ function registerRoutes(ctx, deps) {
4724
4868
  }, 503);
4725
4869
  return;
4726
4870
  }
4727
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
4728
- const rest = url.pathname.replace(/^\/dsh-tiddlywiki\/api/, "") || "/";
4729
4871
  try {
4872
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
4873
+ const rest = url.pathname.replace(/^\/dsh-tiddlywiki\/api/, "") || "/";
4874
+ if (isBlockedProxyPath(rest)) {
4875
+ json(res, {
4876
+ ok: false,
4877
+ error: "system tiddler not exposed"
4878
+ }, 403);
4879
+ return;
4880
+ }
4730
4881
  const headers = forwardHeaders(req.headers);
4731
4882
  const method = (req.method ?? "GET").toUpperCase();
4732
4883
  if (method === "PUT" || method === "DELETE" || method === "POST") headers["x-requested-with"] = "TiddlyWiki";
@@ -4770,14 +4921,23 @@ function registerRoutes(ctx, deps) {
4770
4921
  }
4771
4922
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
4772
4923
  const rest = url.pathname.replace(new RegExp(`^${TW_PROXY_PREFIX}(?=/|$)`), "") || "/";
4924
+ if (isBlockedProxyPath(rest)) {
4925
+ json(res, {
4926
+ ok: false,
4927
+ error: "system tiddler not exposed"
4928
+ }, 403);
4929
+ return;
4930
+ }
4773
4931
  try {
4774
4932
  const method = (req.method ?? "GET").toUpperCase();
4775
4933
  const headers = forwardHeaders(req.headers);
4776
4934
  if (method === "PUT" || method === "DELETE" || method === "POST") headers["x-requested-with"] = "TiddlyWiki";
4935
+ const abort = new AbortController();
4936
+ const timeout = AbortSignal.timeout(3e4);
4777
4937
  const init = {
4778
4938
  method,
4779
4939
  headers,
4780
- signal: AbortSignal.timeout(3e4)
4940
+ signal: typeof AbortSignal.any === "function" ? AbortSignal.any([abort.signal, timeout]) : timeout
4781
4941
  };
4782
4942
  if (method === "PUT" || method === "POST") init.body = await readBodyBuffer(req, MAX_UPLOAD_BYTES);
4783
4943
  const upstream = await fetch(`${deps.server.url}${rest}${url.search}`, init);
@@ -4805,7 +4965,15 @@ function registerRoutes(ctx, deps) {
4805
4965
  await new Promise((resolveP, rejectP) => {
4806
4966
  body.on("error", rejectP);
4807
4967
  res.on("error", rejectP);
4808
- res.on("close", () => resolveP());
4968
+ res.on("close", () => {
4969
+ try {
4970
+ abort.abort();
4971
+ } catch {}
4972
+ try {
4973
+ body.destroy();
4974
+ } catch {}
4975
+ resolveP();
4976
+ });
4809
4977
  res.on("finish", () => resolveP());
4810
4978
  body.pipe(res);
4811
4979
  });
@@ -4824,128 +4992,92 @@ function registerRoutes(ctx, deps) {
4824
4992
  ctx.webServer.register({
4825
4993
  kind: "exact",
4826
4994
  path: `${ROUTE_PREFIX}/status`,
4827
- handler: (req, res) => {
4828
- handleStatus(req, res);
4829
- }
4995
+ handler: guardHandler(handleStatus)
4830
4996
  }),
4831
4997
  ctx.webServer.register({
4832
4998
  kind: "exact",
4833
4999
  path: `${ROUTE_PREFIX}/note`,
4834
- handler: (req, res) => {
4835
- handleNote(req, res);
4836
- }
5000
+ handler: guardHandler(handleNote)
4837
5001
  }),
4838
5002
  ctx.webServer.register({
4839
5003
  kind: "exact",
4840
5004
  path: `${ROUTE_PREFIX}/edit`,
4841
- handler: (req, res) => {
4842
- handleEdit(req, res);
4843
- }
5005
+ handler: guardHandler(handleEdit)
4844
5006
  }),
4845
5007
  ctx.webServer.register({
4846
5008
  kind: "exact",
4847
5009
  path: `${ROUTE_PREFIX}/tags`,
4848
- handler: (req, res) => {
4849
- handleTags(req, res);
4850
- }
5010
+ handler: guardHandler(handleTags)
4851
5011
  }),
4852
5012
  ctx.webServer.register({
4853
5013
  kind: "exact",
4854
5014
  path: `${ROUTE_PREFIX}/recent`,
4855
- handler: (req, res) => {
4856
- handleRecent(req, res);
4857
- }
5015
+ handler: guardHandler(handleRecent)
4858
5016
  }),
4859
5017
  ctx.webServer.register({
4860
5018
  kind: "exact",
4861
5019
  path: `${ROUTE_PREFIX}/get`,
4862
- handler: (req, res) => {
4863
- handleGet(req, res);
4864
- }
5020
+ handler: guardHandler(handleGet)
4865
5021
  }),
4866
5022
  ctx.webServer.register({
4867
5023
  kind: "exact",
4868
5024
  path: `${ROUTE_PREFIX}/search`,
4869
- handler: (req, res) => {
4870
- handleSearch(req, res);
4871
- }
5025
+ handler: guardHandler(handleSearch)
4872
5026
  }),
4873
5027
  ctx.webServer.register({
4874
5028
  kind: "exact",
4875
5029
  path: `${ROUTE_PREFIX}/render`,
4876
- handler: (req, res) => {
4877
- handleRender(req, res);
4878
- }
5030
+ handler: guardHandler(handleRender)
4879
5031
  }),
4880
5032
  ctx.webServer.register({
4881
5033
  kind: "exact",
4882
5034
  path: `${ROUTE_PREFIX}/sync`,
4883
- handler: (req, res) => {
4884
- handleSync(req, res);
4885
- }
5035
+ handler: guardHandler(handleSync)
4886
5036
  }),
4887
5037
  ctx.webServer.register({
4888
5038
  kind: "exact",
4889
5039
  path: `${ROUTE_PREFIX}/upload`,
4890
- handler: (req, res) => {
4891
- handleUpload(req, res);
4892
- }
5040
+ handler: guardHandler(handleUpload)
4893
5041
  }),
4894
5042
  ctx.webServer.register({
4895
5043
  kind: "exact",
4896
5044
  path: `${ROUTE_PREFIX}/restart`,
4897
- handler: (req, res) => {
4898
- handleRestart(req, res);
4899
- }
5045
+ handler: guardHandler(handleRestart)
4900
5046
  }),
4901
5047
  ctx.webServer.register({
4902
5048
  kind: "exact",
4903
5049
  path: `${ROUTE_PREFIX}/session/summary`,
4904
- handler: (req, res) => {
4905
- handleSessionSummary(req, res);
4906
- }
5050
+ handler: guardHandler(handleSessionSummary)
4907
5051
  }),
4908
5052
  ctx.webServer.register({
4909
5053
  kind: "exact",
4910
5054
  path: `${ROUTE_PREFIX}/agent/sessions`,
4911
- handler: (req, res) => {
4912
- handleAgentSessions(req, res);
4913
- }
5055
+ handler: guardHandler(handleAgentSessions)
4914
5056
  }),
4915
5057
  ctx.webServer.register({
4916
5058
  kind: "exact",
4917
5059
  path: `${ROUTE_PREFIX}/agent/modes`,
4918
- handler: (req, res) => {
4919
- handleAgentModes(req, res);
4920
- }
5060
+ handler: guardHandler(handleAgentModes)
4921
5061
  }),
4922
5062
  ctx.webServer.register({
4923
5063
  kind: "exact",
4924
5064
  path: `${ROUTE_PREFIX}/agent/send`,
4925
- handler: (req, res) => {
4926
- handleAgentSend(req, res);
4927
- }
5065
+ handler: guardHandler(handleAgentSend)
4928
5066
  }),
4929
5067
  ctx.webServer.register({
4930
5068
  kind: "exact",
4931
5069
  path: `${ROUTE_PREFIX}/agent/create`,
4932
- handler: (req, res) => {
4933
- handleAgentCreate(req, res);
4934
- }
5070
+ handler: guardHandler(handleAgentCreate)
4935
5071
  }),
4936
5072
  ctx.webServer.register({
4937
5073
  kind: "prefix",
4938
5074
  path: `${ROUTE_PREFIX}/api`,
4939
- handler: (req, res) => {
4940
- handleApiProxy(req, res);
4941
- }
5075
+ handler: guardHandler(handleApiProxy)
4942
5076
  }),
4943
5077
  ctx.webServer.register({
4944
5078
  kind: "prefix",
4945
5079
  path: `${TW_PROXY_PREFIX}`,
4946
- handler: (req, res) => {
4947
- handleTwProxy(req, res);
4948
- }
5080
+ handler: guardHandler(handleTwProxy)
4949
5081
  })
4950
5082
  ];
4951
5083
  return () => {
@@ -5005,12 +5137,14 @@ async function readWikiInfo(wikiPath) {
5005
5137
  } catch {
5006
5138
  throw new Error("tiddlywiki.info 不是合法 JSON;请先修复该文件再保存设置(已保留原文件)");
5007
5139
  }
5140
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("tiddlywiki.info 顶层不是对象;请先修复该文件再保存设置(已保留原文件)");
5141
+ const obj = parsed;
5008
5142
  return {
5009
- ...parsed,
5010
- description: parsed.description,
5011
- plugins: Array.isArray(parsed.plugins) ? parsed.plugins : [],
5012
- themes: Array.isArray(parsed.themes) ? parsed.themes : [],
5013
- languages: Array.isArray(parsed.languages) ? parsed.languages : []
5143
+ ...obj,
5144
+ description: obj.description,
5145
+ plugins: Array.isArray(obj.plugins) ? obj.plugins : [],
5146
+ themes: Array.isArray(obj.themes) ? obj.themes : [],
5147
+ languages: Array.isArray(obj.languages) ? obj.languages : []
5014
5148
  };
5015
5149
  }
5016
5150
  /**
@@ -5164,6 +5298,73 @@ async function ensureLanguage(wikiPath, twRoot, lang) {
5164
5298
  await writeWikiInfo(wikiPath, info);
5165
5299
  return true;
5166
5300
  }
5301
+ /**
5302
+ * Sentinel returned instead of a stored secret (`bridge.token` /
5303
+ * `ui.sendToAgent.token`) by `GET /admin/state` and by `POST /admin/config`'s
5304
+ * echo. `/admin/*` is an unauthenticated surface (only CSRF-hardened), and the
5305
+ * config tiddler legitimately carries shared tokens — handing them to any
5306
+ * caller of the loopback/LAN-reachable GUI is a real leak (v0.19.3). The
5307
+ * settings page renders the sentinel verbatim; posting it back is a no-op
5308
+ * (`stripMaskedSecrets` drops it), so the real value never leaves the host.
5309
+ */
5310
+ const MASKED_SECRET = "********";
5311
+ /** Mask secrets (and credentials inside the git remote URL) for an HTTP caller. */
5312
+ function maskConfigSecrets(config) {
5313
+ const bridge = config.bridge ?? {};
5314
+ const ui = config.ui ?? {};
5315
+ const sendToAgent = ui.sendToAgent ?? {};
5316
+ const git = config.git ?? {};
5317
+ const maskToken = (value) => typeof value === "string" && value.length > 0 ? MASKED_SECRET : "";
5318
+ return {
5319
+ ...config,
5320
+ git: {
5321
+ ...git,
5322
+ remote: typeof git.remote === "string" ? redactRemoteUrl(git.remote) : git.remote
5323
+ },
5324
+ bridge: {
5325
+ ...bridge,
5326
+ token: maskToken(bridge.token),
5327
+ tokenSet: typeof bridge.token === "string" && bridge.token.length > 0
5328
+ },
5329
+ ui: {
5330
+ ...ui,
5331
+ sendToAgent: {
5332
+ ...sendToAgent,
5333
+ token: maskToken(sendToAgent.token),
5334
+ tokenSet: typeof sendToAgent.token === "string" && sendToAgent.token.length > 0
5335
+ }
5336
+ }
5337
+ };
5338
+ }
5339
+ /**
5340
+ * Drop the masked placeholders from an incoming config patch so saving the
5341
+ * settings page never overwrites a stored secret with `********` (or the
5342
+ * redacted git remote with `https://***@…`). `bridge.token`/`ui.sendToAgent.token`
5343
+ * are cleared only when the caller actually sends an empty string.
5344
+ */
5345
+ function stripMaskedSecrets(patch, current) {
5346
+ const copy = { ...patch };
5347
+ const cleanToken = (container) => {
5348
+ if (typeof container !== "object" || container === null) return container;
5349
+ const obj = { ...container };
5350
+ if (obj.token === "********") delete obj.token;
5351
+ delete obj.tokenSet;
5352
+ return obj;
5353
+ };
5354
+ if (copy.bridge !== void 0) copy.bridge = cleanToken(copy.bridge);
5355
+ if (copy.ui !== void 0) {
5356
+ const ui = typeof copy.ui === "object" && copy.ui !== null ? { ...copy.ui } : copy.ui;
5357
+ if (typeof ui === "object" && ui !== null && ui.sendToAgent !== void 0) ui.sendToAgent = cleanToken(ui.sendToAgent);
5358
+ copy.ui = ui;
5359
+ }
5360
+ if (copy.git !== void 0 && typeof copy.git === "object" && copy.git !== null) {
5361
+ const git = { ...copy.git };
5362
+ const storedRemote = typeof current.git?.remote === "string" ? current.git.remote : "";
5363
+ if (typeof git.remote === "string" && storedRemote.length > 0 && git.remote === redactRemoteUrl(storedRemote)) delete git.remote;
5364
+ copy.git = git;
5365
+ }
5366
+ return copy;
5367
+ }
5167
5368
  function registerAdminRoutes(ctx, deps) {
5168
5369
  const handleState = async (req, res) => {
5169
5370
  try {
@@ -5193,7 +5394,7 @@ function registerAdminRoutes(ctx, deps) {
5193
5394
  languages: info.languages ?? []
5194
5395
  },
5195
5396
  catalog,
5196
- config: deps.config.get(),
5397
+ config: maskConfigSecrets(deps.config.get()),
5197
5398
  git
5198
5399
  });
5199
5400
  } catch (err) {
@@ -5221,6 +5422,10 @@ function registerAdminRoutes(ctx, deps) {
5221
5422
  const catalog = await bundledCatalog(deps.twRoot());
5222
5423
  const known = new Set([...catalog.plugins, ...catalog.themes].map((c) => c.name));
5223
5424
  const knownLangs = new Set(catalog.languages.map((c) => c.name));
5425
+ /** Snapshot for the no-op guard below (JSON compare is enough here). */
5426
+ const beforeInfo = JSON.stringify(info);
5427
+ /** Set only when the request carried a themes array with a resolvable active pick. */
5428
+ let activatedTheme;
5224
5429
  const applyList = (field, raw) => {
5225
5430
  if (!Array.isArray(raw)) return info[field];
5226
5431
  const next = [];
@@ -5241,33 +5446,45 @@ function registerAdminRoutes(ctx, deps) {
5241
5446
  }
5242
5447
  return next;
5243
5448
  };
5244
- info.plugins = applyList("plugins", body.plugins);
5245
- let activatedTheme;
5246
- if (Array.isArray(body.themes)) {
5247
- const themeDeps = {};
5248
- for (const theme of catalog.themes) if (theme.dependents && theme.dependents.length > 0) themeDeps[theme.name] = theme.dependents;
5249
- let selected = applyList("themes", body.themes);
5250
- if (typeof body.themeActive === "string" && body.themeActive.length > 0) {
5251
- const activeName = body.themeActive;
5252
- if (known.has(activeName) || info.themes.includes(activeName)) {
5253
- if (!selected.includes(activeName)) selected.push(activeName);
5254
- activatedTheme = activeName;
5449
+ try {
5450
+ info.plugins = applyList("plugins", body.plugins);
5451
+ if (Array.isArray(body.themes)) {
5452
+ const themeDeps = {};
5453
+ for (const theme of catalog.themes) if (theme.dependents && theme.dependents.length > 0) themeDeps[theme.name] = theme.dependents;
5454
+ const selected = applyList("themes", body.themes);
5455
+ if (typeof body.themeActive === "string" && body.themeActive.length > 0) {
5456
+ const activeName = body.themeActive;
5457
+ if (known.has(activeName) || info.themes.includes(activeName)) {
5458
+ if (!selected.includes(activeName)) selected.push(activeName);
5459
+ activatedTheme = activeName;
5460
+ }
5255
5461
  }
5256
- }
5257
- info.themes = normalizeThemes(selected, themeDeps);
5258
- if (activatedTheme === void 0 && info.themes.length > 0) activatedTheme = info.themes[info.themes.length - 1];
5259
- } else info.themes = applyList("themes", body.themes);
5260
- if (Array.isArray(body.languages)) info.languages = applyLanguages(body.languages);
5261
- await writeWikiInfo(wikiPath, info);
5262
- await deps.server.restart();
5263
- if (activatedTheme !== void 0) {
5462
+ info.themes = normalizeThemes(selected, themeDeps);
5463
+ if (activatedTheme === void 0 && info.themes.length > 0) activatedTheme = info.themes[info.themes.length - 1];
5464
+ } else info.themes = applyList("themes", body.themes);
5465
+ if (Array.isArray(body.languages)) info.languages = applyLanguages(body.languages);
5466
+ } catch (err) {
5467
+ json(res, {
5468
+ ok: false,
5469
+ error: err instanceof Error ? err.message : String(err)
5470
+ }, 400);
5471
+ return;
5472
+ }
5473
+ const changed = JSON.stringify(info) !== beforeInfo;
5474
+ if (changed) {
5475
+ await writeWikiInfo(wikiPath, info);
5476
+ await deps.server.restart();
5477
+ }
5478
+ if (Array.isArray(body.themes) && activatedTheme !== void 0) {
5264
5479
  const client = deps.getClient();
5265
5480
  if (client !== void 0) await client.put({
5266
5481
  title: "$:/theme",
5267
5482
  text: `$:/themes/${activatedTheme}`,
5268
5483
  type: "text/vnd.tiddlywiki",
5269
5484
  tags: []
5270
- }).catch(() => void 0);
5485
+ }).catch((err) => {
5486
+ console.warn("[dsh-tiddlywiki] activating theme failed:", err);
5487
+ });
5271
5488
  }
5272
5489
  if (Array.isArray(body.languages)) {
5273
5490
  const client = deps.getClient();
@@ -5279,13 +5496,16 @@ function registerAdminRoutes(ctx, deps) {
5279
5496
  text: active,
5280
5497
  type: "text/plain",
5281
5498
  tags: []
5282
- }).catch(() => void 0);
5499
+ }).catch((err) => {
5500
+ console.warn("[dsh-tiddlywiki] pinning $:/language failed:", err);
5501
+ });
5283
5502
  const hint = langs.length > 0 ? langs[0] : "";
5284
5503
  if ((deps.config.get().uiLanguage ?? "") !== hint) await deps.config.set(client, { uiLanguage: hint }).catch(() => void 0);
5285
5504
  }
5286
5505
  }
5287
5506
  json(res, {
5288
5507
  ok: true,
5508
+ changed,
5289
5509
  info: {
5290
5510
  plugins: info.plugins,
5291
5511
  themes: info.themes,
@@ -5296,7 +5516,7 @@ function registerAdminRoutes(ctx, deps) {
5296
5516
  json(res, {
5297
5517
  ok: false,
5298
5518
  error: err instanceof Error ? err.message : String(err)
5299
- }, 400);
5519
+ }, 500);
5300
5520
  }
5301
5521
  };
5302
5522
  const handleConfig = async (req, res) => {
@@ -5311,10 +5531,10 @@ function registerAdminRoutes(ctx, deps) {
5311
5531
  }, 503);
5312
5532
  return;
5313
5533
  }
5314
- await deps.config.set(client, body);
5534
+ await deps.config.set(client, stripMaskedSecrets(body, deps.config.get()));
5315
5535
  json(res, {
5316
5536
  ok: true,
5317
- config: deps.config.get()
5537
+ config: maskConfigSecrets(deps.config.get())
5318
5538
  });
5319
5539
  } catch (err) {
5320
5540
  json(res, {
@@ -5447,51 +5667,37 @@ function registerAdminRoutes(ctx, deps) {
5447
5667
  ctx.webServer.register({
5448
5668
  kind: "exact",
5449
5669
  path: `${ROUTE_PREFIX}/admin/state`,
5450
- handler: (req, res) => {
5451
- handleState(req, res);
5452
- }
5670
+ handler: guardHandler(handleState)
5453
5671
  }),
5454
5672
  ctx.webServer.register({
5455
5673
  kind: "exact",
5456
5674
  path: `${ROUTE_PREFIX}/admin/info`,
5457
- handler: (req, res) => {
5458
- handleInfo(req, res);
5459
- }
5675
+ handler: guardHandler(handleInfo)
5460
5676
  }),
5461
5677
  ctx.webServer.register({
5462
5678
  kind: "exact",
5463
5679
  path: `${ROUTE_PREFIX}/admin/config`,
5464
- handler: (req, res) => {
5465
- handleConfig(req, res);
5466
- }
5680
+ handler: guardHandler(handleConfig)
5467
5681
  }),
5468
5682
  ctx.webServer.register({
5469
5683
  kind: "exact",
5470
5684
  path: `${ROUTE_PREFIX}/admin/restart`,
5471
- handler: (req, res) => {
5472
- handleRestart(req, res);
5473
- }
5685
+ handler: guardHandler(handleRestart)
5474
5686
  }),
5475
5687
  ctx.webServer.register({
5476
5688
  kind: "exact",
5477
5689
  path: `${ROUTE_PREFIX}/admin/seeds`,
5478
- handler: (req, res) => {
5479
- handleSeeds(req, res);
5480
- }
5690
+ handler: guardHandler(handleSeeds)
5481
5691
  }),
5482
5692
  ctx.webServer.register({
5483
5693
  kind: "exact",
5484
5694
  path: `${ROUTE_PREFIX}/admin/seeds/run`,
5485
- handler: (req, res) => {
5486
- handleSeedsRun(req, res);
5487
- }
5695
+ handler: guardHandler(handleSeedsRun)
5488
5696
  }),
5489
5697
  ctx.webServer.register({
5490
5698
  kind: "exact",
5491
5699
  path: `${ROUTE_PREFIX}/admin/seeds/remove`,
5492
- handler: (req, res) => {
5493
- handleSeedsRemove(req, res);
5494
- }
5700
+ handler: guardHandler(handleSeedsRemove)
5495
5701
  })
5496
5702
  ];
5497
5703
  return () => {
@@ -5565,8 +5771,16 @@ const PRIVATE_V4_PATTERNS = [
5565
5771
  /^169\.254\./,
5566
5772
  /^172\.(1[6-9]|2\d|3[01])\./,
5567
5773
  /^192\.0\.0\./,
5774
+ /^192\.0\.2\./,
5775
+ /^192\.88\.99\./,
5568
5776
  /^192\.168\./,
5569
- /^198\.1[89]\./
5777
+ /^198\.1[89]\./,
5778
+ /^198\.51\.100\./,
5779
+ /^203\.0\.113\./,
5780
+ /^22[4-9]\./,
5781
+ /^23\d\./,
5782
+ /^24\d\./,
5783
+ /^25[0-5]\./
5570
5784
  ];
5571
5785
  /** Parse a dotted-quad IPv4 into 4 bytes, or null. */
5572
5786
  function parseIpv4(input) {
@@ -6045,6 +6259,9 @@ var ClipBridge = class {
6045
6259
  };
6046
6260
  const onListening = () => {
6047
6261
  server.removeListener("error", onError);
6262
+ server.on("error", (err) => {
6263
+ this.deps.log?.(`server error after listen: ${err.message}`);
6264
+ });
6048
6265
  const addr = server.address();
6049
6266
  this.boundPort = addr.port;
6050
6267
  resolve();
@@ -6061,7 +6278,17 @@ var ClipBridge = class {
6061
6278
  this.boundPort = 0;
6062
6279
  if (server === void 0) return Promise.resolve();
6063
6280
  return new Promise((resolve) => {
6064
- server.close(() => resolve());
6281
+ let done = false;
6282
+ const finish = () => {
6283
+ if (done) return;
6284
+ done = true;
6285
+ resolve();
6286
+ };
6287
+ try {
6288
+ server.closeAllConnections?.();
6289
+ } catch {}
6290
+ server.close(() => finish());
6291
+ setTimeout(finish, 1e3).unref?.();
6065
6292
  });
6066
6293
  }
6067
6294
  respond(res, payload, status = 200) {
@@ -6721,8 +6948,11 @@ function registerTiddlywikiTools(ctx, deps) {
6721
6948
  }));
6722
6949
  register(defineTool({
6723
6950
  name: "tiddlywiki_list_tags",
6724
- description: "列出 TiddlyWiki 知识库现有的非系统标签及各自计数(按使用次数降序),方便决定给笔记打什么 tag。",
6725
- parameters: {},
6951
+ description: "列出 TiddlyWiki 知识库现有的非系统标签及各自计数(按使用次数降序),方便决定给笔记打什么 tag。默认最多返回 200 个(`limit` 可调,上限 1000),被截断时结果里会带 total/truncated。",
6952
+ parameters: { limit: {
6953
+ type: "integer",
6954
+ description: "可选:最多返回多少个标签(按使用次数降序),默认 200,上限 1000。"
6955
+ } },
6726
6956
  output: {
6727
6957
  schema: { type: "json" },
6728
6958
  render: (_args, value) => {
@@ -6730,18 +6960,23 @@ function registerTiddlywikiTools(ctx, deps) {
6730
6960
  type: "text",
6731
6961
  text: "知识库暂无标签。"
6732
6962
  }];
6733
- const lines = [`现有标签(${value.tags.length} 个,按使用次数降序):`];
6963
+ const lines = [value.truncated ? `现有标签(共 ${value.total} 个,仅列出使用最多的 ${value.tags.length} 个,按使用次数降序):` : `现有标签(${value.total} 个,按使用次数降序):`];
6734
6964
  for (const t of value.tags) lines.push(`- ${t.tag} × ${t.count}`);
6965
+ if (value.truncated) lines.push(`(其余 ${value.total - value.tags.length} 个较少使用的标签未列出;需要时可提高 limit 重试)`);
6735
6966
  return [{
6736
6967
  type: "text",
6737
6968
  text: lines.join("\n")
6738
6969
  }];
6739
6970
  }
6740
6971
  },
6741
- execute: async () => {
6742
- const tags = await requireWiki().listTags();
6972
+ execute: async (args) => {
6973
+ const stats = await requireWiki().tagStats();
6974
+ const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? Math.max(1, Math.min(Math.floor(args.limit), 1e3)) : 200;
6975
+ const tags = stats.tags.slice(0, limit);
6743
6976
  return {
6744
6977
  count: tags.length,
6978
+ total: stats.total,
6979
+ truncated: tags.length < stats.total,
6745
6980
  tags
6746
6981
  };
6747
6982
  }
@@ -8318,6 +8553,6 @@ function apply(ctx, rawConfig = {}) {
8318
8553
  }, "dsh-tiddlywiki: host teardown");
8319
8554
  }
8320
8555
  //#endregion
8321
- export { AGENT_WRITTEN_TAG, ALL_ARTICLES_MARKER_TITLE, ALL_ARTICLES_TEXT, ALL_ARTICLES_TITLE, AutoCommitter, CLIP_BRIDGE_BOOKMARKLET, CLIP_BRIDGE_DEFAULT_PORT, CLIP_BRIDGE_DOC_TEXT, CLIP_BRIDGE_DOC_TITLE, CLIP_BRIDGE_DRAG_HREF, CLIP_BRIDGE_MARKER_TITLE, ClipBridge, ConfigStore, DEFAULT_NOTE_TYPE, DOC_NOTE_TAG, DOC_NOTE_TEXT, DOC_NOTE_TITLE, DSH_DOCS_TAG, GitFace, HOME_DEFAULT_TIDDLERS, HOME_INDEX_ITEMS, HOME_INDEX_MARKER_TITLE, HUMAN_EDITED_TAG, MARKDOWN_PLUGIN, MENUBAR_THEME_MARKER_TITLE, MENUBAR_THEME_TEXT, MENUBAR_THEME_TIDDLER, MISSING_TYPE_FILTER, PATH_PREFIX, RENDER_BUNDLE_TEXT, RENDER_MARKER_TITLE, RENDER_PLUGIN_FILE, RENDER_PLUGIN_TITLE, SEED_DEFS, SEND_TO_AGENT_BUNDLE_TEXT, SEND_TO_AGENT_MARKER_TITLE, SEND_TO_AGENT_PLUGIN_TITLE, SESSION_SUMMARY_PREFIX, STARTER_DOCS_ITEMS, STARTER_DOCS_MARKER_TITLE, TEXT_LIST_FILTER, TW_PROXY_PATH, TW_PROXY_PREFIX, TW_WEB_HOST_DEFAULT, TW_WEB_HOST_TIDDLER, TiddlyWebClient, UI_STYLES_MARKER_TITLE, UI_STYLE_ITEMS, WikiServer, WriteConflictError, apply, assertNoConflict, assertPublicImageUrl, buildBinaryTiddler, buildClipTiddler, buildImageNoteTiddler, buildWriteTiddler, bundledCatalog, checkAllSeeds, cleanTiddler, deepMerge, defineTool, downloadClipImage, dshHomePath, ensureLanguage, ensurePlugin, ensureTwWebHost, flattenTiddlerFields, flushPendingWrites, hostAllowed, imageExtensionForMime, inject, isBinaryType, isPrivateAddress, isSafeUrl, name, needsRestartAfterSeeds, normalizeThemes, openInTwEditor, parseClipPayload, pickImageMime, readWikiInfo, registerAdminRoutes, registerRoutes, registerTiddlywikiTools, removeSeedById, resolveClipTitle, resolveTwRoot, runAllSeeds, runSeedById, sanitizeTwFragment, seedAllArticles, seedClipBridge, seedDocNote, seedHomeIndex, seedMenubarTheme, seedRenderRoute, seedSendToAgent, seedStarterDocs, seedUiStyles, unseedClipBridge, waitForFileWrite, writeSessionSummary, writeWikiInfo };
8556
+ export { AGENT_WRITTEN_TAG, ALL_ARTICLES_MARKER_TITLE, ALL_ARTICLES_TEXT, ALL_ARTICLES_TITLE, AutoCommitter, CLIP_BRIDGE_BOOKMARKLET, CLIP_BRIDGE_DEFAULT_PORT, CLIP_BRIDGE_DOC_TEXT, CLIP_BRIDGE_DOC_TITLE, CLIP_BRIDGE_DRAG_HREF, CLIP_BRIDGE_MARKER_TITLE, ClipBridge, ConfigStore, DEFAULT_NOTE_TYPE, DOC_NOTE_TAG, DOC_NOTE_TEXT, DOC_NOTE_TITLE, DSH_DOCS_TAG, GitFace, HOME_DEFAULT_TIDDLERS, HOME_INDEX_ITEMS, HOME_INDEX_MARKER_TITLE, HUMAN_EDITED_TAG, MARKDOWN_PLUGIN, MASKED_SECRET, MENUBAR_THEME_MARKER_TITLE, MENUBAR_THEME_TEXT, MENUBAR_THEME_TIDDLER, MISSING_TYPE_FILTER, PATH_PREFIX, RENDER_BUNDLE_TEXT, RENDER_MARKER_TITLE, RENDER_PLUGIN_FILE, RENDER_PLUGIN_TITLE, SEED_DEFS, SEND_TO_AGENT_BUNDLE_TEXT, SEND_TO_AGENT_MARKER_TITLE, SEND_TO_AGENT_PLUGIN_TITLE, SESSION_SUMMARY_PREFIX, STARTER_DOCS_ITEMS, STARTER_DOCS_MARKER_TITLE, TEXT_LIST_FILTER, TW_PROXY_PATH, TW_PROXY_PREFIX, TW_WEB_HOST_DEFAULT, TW_WEB_HOST_TIDDLER, TiddlyWebClient, UI_STYLES_MARKER_TITLE, UI_STYLE_ITEMS, WikiServer, WriteConflictError, apply, assertNoConflict, assertPublicImageUrl, buildBinaryTiddler, buildClipTiddler, buildImageNoteTiddler, buildWriteTiddler, bundledCatalog, checkAllSeeds, cleanTiddler, deepMerge, defineTool, downloadClipImage, dshHomePath, ensureLanguage, ensurePlugin, ensureTwWebHost, escapeInline, flattenTiddlerFields, flushPendingWrites, hostAllowed, imageExtensionForMime, inject, isBinaryType, isPrivateAddress, isSafeUrl, maskConfigSecrets, name, needsRestartAfterSeeds, normalizeThemes, openInTwEditor, parseClipPayload, pickImageMime, readWikiInfo, registerAdminRoutes, registerRoutes, registerTiddlywikiTools, removeSeedById, resolveClipTitle, resolveTwRoot, runAllSeeds, runSeedById, sanitizeTwFragment, seedAllArticles, seedClipBridge, seedDocNote, seedHomeIndex, seedMenubarTheme, seedRenderRoute, seedSendToAgent, seedStarterDocs, seedUiStyles, stripMaskedSecrets, unseedClipBridge, waitForFileWrite, writeSessionSummary, writeWikiInfo };
8322
8557
 
8323
8558
  //# sourceMappingURL=index.js.map