dsh-tiddlywiki 0.27.2 → 0.28.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.
Files changed (41) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +3 -0
  3. package/cordis.patch.yml +13 -13
  4. package/docs/CHANGELOG.md +2 -1
  5. package/docs/code-review-2026-09-17.md +151 -0
  6. package/docs/features.md +5 -2
  7. package/docs/usage.md +34 -3
  8. package/docs/wiki-split.md +97 -0
  9. package/lib/client.js +54 -27
  10. package/lib/index.js +2803 -517
  11. package/lib/index.js.map +1 -1
  12. package/package.json +5 -4
  13. package/skills/wiki-split/SKILL.md +106 -0
  14. package/src/client/endpoints.ts +37 -0
  15. package/src/client/index.ts +14 -0
  16. package/src/client/knowledge-fab.ts +62 -3
  17. package/src/client/markdown-editor.ts +128 -128
  18. package/src/client/panel.ts +2 -1
  19. package/src/client/settings-page.ts +217 -3
  20. package/src/client/state.ts +39 -39
  21. package/src/client/status-cache.ts +17 -0
  22. package/src/client/styles.ts +27 -0
  23. package/src/client/toast.ts +22 -22
  24. package/src/client/tw-frame.ts +28 -4
  25. package/src/client/wiki-focus.ts +70 -0
  26. package/src/client/wiki-scope-dock.ts +116 -0
  27. package/src/host/admin.ts +175 -35
  28. package/src/host/git.ts +50 -5
  29. package/src/host/path-key.ts +35 -0
  30. package/src/host/prompt.ts +24 -0
  31. package/src/host/repo-committers.ts +149 -0
  32. package/src/host/routes.ts +254 -81
  33. package/src/host/seeds.ts +51 -10
  34. package/src/host/session-scope.ts +154 -0
  35. package/src/host/skill-install.ts +121 -0
  36. package/src/host/tools.ts +141 -49
  37. package/src/host/wiki-farm.ts +417 -0
  38. package/src/host/wiki-instance.ts +596 -0
  39. package/src/host/wiki-registry.ts +601 -0
  40. package/src/host/wiki.ts +19 -0
  41. package/src/index.ts +686 -459
@@ -193,14 +193,73 @@ export interface UiDefaultsPublic {
193
193
  showRightbarTab: boolean
194
194
  }
195
195
 
196
+ /** One knowledge base as the GUI sees it (list/selector payload). */
197
+ export interface WikiSummaryPublic {
198
+ id: string
199
+ label: string
200
+ /** Health of this wiki's TW child ('running' | 'starting' | 'stopped' | 'failed'). */
201
+ status: string
202
+ /** May the AGENT reach it at all (hidden ones are absent from every prompt). */
203
+ agentVisible: boolean
204
+ /** Comes up at boot (multi mode); others start on demand. */
205
+ autostart: boolean
206
+ /** A TW child is currently serving it. */
207
+ running: boolean
208
+ path: string
209
+ }
210
+
211
+ /** The whole farm as the GUI sees it. */
212
+ export interface WikiFarmPublic {
213
+ /** 'single' = one wiki (legacy behaviour); 'multi' = the farm. */
214
+ mode: string
215
+ /** Id a session without an explicit scope falls back to. */
216
+ defaultId: string
217
+ items: WikiSummaryPublic[]
218
+ }
219
+
220
+ /**
221
+ * Everything the routes need from the plugin.
222
+ *
223
+ * v0.28.0: every PER-WIKI accessor takes the request, because the plugin no
224
+ * longer serves exactly one knowledge base. In single-wiki mode they all
225
+ * resolve to the same instance, so the call sites read exactly as before; in
226
+ * multi-wiki mode they resolve the wiki THIS request targets (`?wiki=<id>`, or
227
+ * the session's scope — see host/wiki-farm.ts).
228
+ *
229
+ * ⚠️ `server` returns `undefined` when the targeted wiki is not running. The
230
+ * routes then answer 503 — the same answer the previous
231
+ * `getClient() === undefined` guard already produced.
232
+ *
233
+ * The DSH *session* services below stay request-independent on purpose: they
234
+ * belong to the host, not to a wiki.
235
+ */
196
236
  export interface RouteDeps {
197
- server: WikiServer
198
- getClient: () => TiddlyWebClient | undefined
237
+ /** The whole farm's list/mode/default (the GUI's per-wiki selector). */
238
+ wikiSummaries: (req: IncomingMessage) => WikiFarmPublic
239
+ /** The TW child serving the wiki this request targets (undefined = not up). */
240
+ server: (req: IncomingMessage) => WikiServer | undefined
241
+ /** Lazily resolved REST client for the same wiki. */
242
+ getClient: (req: IncomingMessage) => TiddlyWebClient | undefined
243
+ /** The TW child of a NAMED wiki (the `/tw/<id>/…` proxy form). */
244
+ serverById: (id: string) => WikiServer | undefined
245
+ /** Every registered wiki id (lets the proxy tell an id from a TW path). */
246
+ wikiIds: () => readonly string[]
199
247
  git: GitFace
200
- autoCommit: () => void
201
- noteDefaults: () => { tag: string }
202
- uiDefaults: () => UiDefaultsPublic
203
- getWikiPath: () => string
248
+ /** Debounced auto-commit touch for the targeted wiki. */
249
+ autoCommit: (req: IncomingMessage) => void
250
+ /** Default note tag of the targeted wiki. */
251
+ noteDefaults: (req: IncomingMessage) => { tag: string }
252
+ /** Effective UI flags of the targeted wiki. */
253
+ uiDefaults: (req: IncomingMessage) => UiDefaultsPublic
254
+ /** Absolute folder of the targeted wiki. */
255
+ getWikiPath: (req: IncomingMessage) => string
256
+ /**
257
+ * After a pull moved HEAD: restart the running wikis whose content actually
258
+ * changed, and report their ids (v0.28.0). `changedFiles` are
259
+ * repository-relative paths, so a shared repository can restart exactly the
260
+ * affected knowledge bases. Absent in headless contexts (no restart).
261
+ */
262
+ restartAffected?: (req: IncomingMessage, dir: string, changedFiles: readonly string[]) => Promise<{ restarted: string[]; failed: Array<{ id: string; message: string }> }>
204
263
  /** Optional DSH sessionController service (agent-send routes only); resolved
205
264
  * lazily per request because it may register after webServer appears. */
206
265
  getSessionController: () => SessionControllerFace | undefined
@@ -224,20 +283,33 @@ export interface RouteDeps {
224
283
  * session's complete event log + descendant tree to decide which wiki notes
225
284
  * belong to this conversation. Resolved lazily like the other services. */
226
285
  getSessionQuery: () => SessionQueryFace | undefined
227
- /** Whether the one-click send-to-agent feature is enabled (config switch). */
228
- sendToAgentEnabled: () => boolean
286
+ /** Whether the one-click send-to-agent feature is enabled for that wiki. */
287
+ sendToAgentEnabled: (req: IncomingMessage) => boolean
229
288
  /** Optional shared token that must match `x-send-to-agent-token` when set. */
230
- sendToAgentToken: () => string
289
+ sendToAgentToken: (req: IncomingMessage) => string
231
290
  /**
232
- * Effective `wechat.*` config (v0.23.3): the opt-in 公众号发布 feature's
233
- * switch, CLI override, optional token and default adapter. Read per request
234
- * so a settings-page save applies without a dsh web restart.
291
+ * Effective `wechat.*` config (v0.23.3) OF THE TARGETED WIKI: the opt-in
292
+ * 公众号发布 feature's switch, CLI override, optional token and default
293
+ * adapter. Read per request so a settings-page save applies without a dsh web
294
+ * restart.
235
295
  */
236
- wechatConfig: () => WechatPublishConfig
237
- /** The publish runner, or undefined when the host has not wired one. */
296
+ wechatConfig: (req: IncomingMessage) => WechatPublishConfig
297
+ /** The publish runner (plugin-wide: one opencli child at a time). */
238
298
  wechatRunner: () => WechatPublishFace | undefined
239
299
  /** Readiness report (opencli + adapter files) for the button's precheck. */
240
- wechatReady: () => Promise<WechatReadyView>
300
+ wechatReady: (req: IncomingMessage) => Promise<WechatReadyView>
301
+ /**
302
+ * Per-session knowledge-base scope (v0.28.0): what this conversation works on.
303
+ * The composer's selector reads and writes it; the agent tools and the
304
+ * injected prompt are driven by the same value, so what the user picks is what
305
+ * the model gets.
306
+ */
307
+ sessionScope?: {
308
+ /** Current choice + what it resolves to (and why, when it cannot serve). */
309
+ get: (sessionId: string) => { scope?: string; resolved?: { id: string; label: string }; reason?: string }
310
+ /** Point the session at a wiki, or clear the choice with `undefined`. */
311
+ set: (sessionId: string, wikiId: string | undefined) => Promise<void>
312
+ }
241
313
  }
242
314
 
243
315
  /**
@@ -531,16 +603,19 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
531
603
  * invalidates it so a sync/upload is reflected immediately.
532
604
  */
533
605
  const GIT_STATUS_TTL_MS = 2_000
534
- /** Cached git-status PROMISE (not value): a burst of concurrent /status polls
535
- * then shares ONE probe instead of each spawning up to five git processes
536
- * (v0.19.0 — value-caching still let every concurrent miss run its own). */
537
- let gitStatusCache: { at: number; value: Promise<GitStatusViewPublic | null> } | undefined
606
+ /** Cached git-status PROMISE **per wiki** (not value): a burst of concurrent
607
+ * /status polls then shares ONE probe instead of each spawning up to five git
608
+ * processes (v0.19.0 — value-caching still let every concurrent miss run its
609
+ * own). The key is the wiki folder: with several knowledge bases a
610
+ * single-slot cache would hand wiki A the answer probed for wiki B (v0.28.0). */
611
+ let gitStatusCache: { key: string; at: number; value: Promise<GitStatusViewPublic | null> } | undefined
538
612
  const invalidateGitStatus = (): void => { gitStatusCache = undefined }
539
- const cachedGitStatus = (): Promise<GitStatusViewPublic | null> => {
540
- if (gitStatusCache !== undefined && Date.now() - gitStatusCache.at < GIT_STATUS_TTL_MS) return gitStatusCache.value
613
+ const cachedGitStatus = (req: IncomingMessage): Promise<GitStatusViewPublic | null> => {
614
+ const key = deps.getWikiPath(req)
615
+ if (gitStatusCache !== undefined && gitStatusCache.key === key && Date.now() - gitStatusCache.at < GIT_STATUS_TTL_MS) return gitStatusCache.value
541
616
  const pending = (async (): Promise<GitStatusViewPublic | null> => {
542
617
  try {
543
- const view = await deps.git.status(deps.getWikiPath())
618
+ const view = await deps.git.status(key)
544
619
  // The remote URL can carry a PAT (`https://user:token@…`) and /status is
545
620
  // unauthenticated — never hand the credential to the browser.
546
621
  return { ...view, remote: redactRemoteUrl(typeof view.remote === 'string' ? view.remote : '') }
@@ -548,7 +623,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
548
623
  return null
549
624
  }
550
625
  })()
551
- gitStatusCache = { at: Date.now(), value: pending }
626
+ gitStatusCache = { key, at: Date.now(), value: pending }
552
627
  return pending
553
628
  }
554
629
 
@@ -639,8 +714,8 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
639
714
 
640
715
  const handleStatus = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
641
716
  if (rejectNonRead(req, res)) return
642
- const view = deps.server.status()
643
- const gitSummary = await cachedGitStatus()
717
+ const view = deps.server(req)?.status() ?? { status: 'stopped' as const, wikiPath: deps.getWikiPath(req), logs: [] }
718
+ const gitSummary = await cachedGitStatus(req)
644
719
  json(res, {
645
720
  ok: true,
646
721
  ...view,
@@ -653,8 +728,16 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
653
728
  // twProxyAbsoluteBase(). Absent when the Host header is unusable.
654
729
  twProxyAbsolute: twProxyAbsoluteBase(req),
655
730
  git: gitSummary,
656
- note: { tag: deps.noteDefaults().tag },
657
- ui: deps.uiDefaults(),
731
+ note: { tag: deps.noteDefaults(req).tag },
732
+ ui: deps.uiDefaults(req),
733
+ // The knowledge-base roster (v0.28.0): the GUI's per-wiki selector and the
734
+ // settings page read it from here. Deliberately WITHOUT per-wiki git
735
+ // status — that would spawn up to five git processes per wiki on every
736
+ // 30s poll; `/status?wiki=<id>` carries git for the one you are looking at.
737
+ ...(() => {
738
+ const farm = deps.wikiSummaries(req)
739
+ return { mode: farm.mode, defaultId: farm.defaultId, wikis: farm.items }
740
+ })(),
658
741
  })
659
742
  }
660
743
 
@@ -670,6 +753,80 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
670
753
  const SUMMARY_REUSE_MS = 3_000
671
754
  const summaryInFlight = new Map<string, { at: number; value: Promise<SessionSummaryResult> }>()
672
755
 
756
+ /**
757
+ * GET /dsh-tiddlywiki/session/wiki?session=<id> — which knowledge base this
758
+ * conversation works on, plus what it resolves to right now.
759
+ *
760
+ * The per-wiki selector in the composer reads this; the agent tools and the
761
+ * injected prompt are driven by the SAME value, so what the user picks is what
762
+ * the model gets.
763
+ */
764
+ const handleSessionWiki = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
765
+ try {
766
+ if (rejectNonRead(req, res)) return
767
+ if (deps.sessionScope === undefined) {
768
+ json(res, { ok: false, error: '会话作用域不可用(宿主未接线)' }, 503)
769
+ return
770
+ }
771
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
772
+ const session = (url.searchParams.get('session') ?? '').trim()
773
+ if (session.length === 0) {
774
+ json(res, { ok: false, error: 'session is required' }, 400)
775
+ return
776
+ }
777
+ json(res, { ok: true, session, ...deps.sessionScope.get(session) })
778
+ } catch (err) {
779
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
780
+ }
781
+ }
782
+
783
+ /**
784
+ * POST /dsh-tiddlywiki/session/wiki { session, wiki } — point this conversation
785
+ * at a knowledge base (`wiki: null`/`''` clears the choice and falls back to
786
+ * the default).
787
+ *
788
+ * A HIDDEN wiki is refused (400): `agentVisible: false` means "the agent never
789
+ * reaches this one", and a selector that lets a session pick it would quietly
790
+ * contradict the setting the user just made.
791
+ */
792
+ const handleSessionWikiSet = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
793
+ try {
794
+ if (deps.sessionScope === undefined) {
795
+ json(res, { ok: false, error: '会话作用域不可用(宿主未接线)' }, 503)
796
+ return
797
+ }
798
+ let body: { session?: unknown; wiki?: unknown } = {}
799
+ try {
800
+ body = JSON.parse(await readBody(req)) as { session?: unknown; wiki?: unknown }
801
+ } catch {
802
+ json(res, { ok: false, error: '请求体必须是 JSON' }, 400)
803
+ return
804
+ }
805
+ const session = typeof body.session === 'string' ? body.session.trim() : ''
806
+ if (session.length === 0) {
807
+ json(res, { ok: false, error: 'session is required' }, 400)
808
+ return
809
+ }
810
+ const raw = typeof body.wiki === 'string' ? body.wiki.trim() : ''
811
+ const wikiId = raw.length === 0 ? undefined : raw
812
+ await deps.sessionScope.set(session, wikiId)
813
+ json(res, { ok: true, session, ...deps.sessionScope.get(session) })
814
+ } catch (err) {
815
+ // A refusal from the host (unknown / hidden wiki, bad session id) is the
816
+ // caller's problem: 400 + the reason, so the picker can say what happened.
817
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 400)
818
+ }
819
+ }
820
+
821
+ /** `/session/wiki` takes both methods on ONE registration (see /admin/wikis). */
822
+ const handleSessionWikiRoute = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
823
+ if ((req.method ?? 'GET').toUpperCase() === 'POST') {
824
+ if (rejectCrossSiteWrite(req, res, ['POST'])) return
825
+ return handleSessionWikiSet(req, res)
826
+ }
827
+ return handleSessionWiki(req, res)
828
+ }
829
+
673
830
  /**
674
831
  * POST /dsh-tiddlywiki/session/summary — 生成当前会话的 wiki 汇总页(「知识库」
675
832
  * Tab 的后端)。body `{ session: <会话ID> }`;后端用 sessionQuery 读本会话(含
@@ -698,7 +855,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
698
855
  json(res, { ok: false, error: 'session id has an unsupported format' }, 400)
699
856
  return
700
857
  }
701
- const client = deps.getClient()
858
+ const client = deps.getClient(req)
702
859
  if (client === undefined) {
703
860
  json(res, { ok: false, error: 'wiki service is not running' }, 503)
704
861
  return
@@ -783,11 +940,11 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
783
940
  * error response — the caller just does `if (!guard(...)) return`.
784
941
  */
785
942
  const guardSendToAgent = (req: IncomingMessage, res: ServerResponse): boolean => {
786
- if (!deps.sendToAgentEnabled()) {
943
+ if (!deps.sendToAgentEnabled(req)) {
787
944
  json(res, { ok: false, error: 'send-to-agent is disabled' }, 403)
788
945
  return false
789
946
  }
790
- const token = deps.sendToAgentToken().trim()
947
+ const token = deps.sendToAgentToken(req).trim()
791
948
  if (token.length === 0) return true
792
949
  const got = req.headers['x-send-to-agent-token']
793
950
  const value = typeof got === 'string' ? got : Array.isArray(got) ? got[0] ?? '' : ''
@@ -1023,7 +1180,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1023
1180
  json(res, { ok: false, error: 'text is required' }, 400)
1024
1181
  return
1025
1182
  }
1026
- const client = deps.getClient()
1183
+ const client = deps.getClient(req)
1027
1184
  if (client === undefined) {
1028
1185
  json(res, { ok: false, error: 'wiki service is not running' }, 503)
1029
1186
  return
@@ -1046,11 +1203,11 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1046
1203
  const { tiddler } = buildWriteTiddler(title, text, {
1047
1204
  existing,
1048
1205
  tags,
1049
- defaultTags: [deps.noteDefaults().tag],
1206
+ defaultTags: [deps.noteDefaults(req).tag],
1050
1207
  agentTag: false,
1051
1208
  })
1052
1209
  await client.put(tiddler)
1053
- deps.autoCommit()
1210
+ deps.autoCommit(req)
1054
1211
  invalidateGitStatus()
1055
1212
  const finalTags = tiddler.tags ?? []
1056
1213
  json(res, { ok: true, title, tag: finalTags.join(' '), tags: finalTags, text, type: typeof tiddler.type === 'string' ? tiddler.type : NOTE_TYPE })
@@ -1067,7 +1224,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1067
1224
  try {
1068
1225
  if (rejectCrossSiteWrite(req, res, ['POST'])) return
1069
1226
  const body = JSON.parse(await readBody(req)) as { title?: unknown; tag?: unknown; tags?: unknown; text?: unknown; expectedModified?: unknown; expectedRevision?: unknown; force?: unknown }
1070
- const client = deps.getClient()
1227
+ const client = deps.getClient(req)
1071
1228
  if (client === undefined) {
1072
1229
  json(res, { ok: false, error: 'wiki service is not running' }, 503)
1073
1230
  return
@@ -1079,10 +1236,10 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1079
1236
  }
1080
1237
  const text = typeof body.text === 'string' ? body.text : ''
1081
1238
  const result = await openInTwEditor(client, title, text, resolveTags(body), {
1082
- defaultTags: [deps.noteDefaults().tag],
1239
+ defaultTags: [deps.noteDefaults(req).tag],
1083
1240
  ...conflictTokens(body),
1084
1241
  })
1085
- deps.autoCommit()
1242
+ deps.autoCommit(req)
1086
1243
  invalidateGitStatus()
1087
1244
  json(res, { ok: true, ...result, twUrl: TW_PROXY_PATH, twUrlAbsolute: twProxyAbsoluteBase(req) })
1088
1245
  } catch (err) {
@@ -1105,7 +1262,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1105
1262
  * order, plus `total`/`truncated` so a capped caller can say "N of M". */
1106
1263
  const handleTags = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
1107
1264
  if (rejectNonRead(req, res)) return
1108
- const client = deps.getClient()
1265
+ const client = deps.getClient(req)
1109
1266
  if (client === undefined) {
1110
1267
  json(res, { ok: false, error: 'wiki service is not running' }, 503)
1111
1268
  return
@@ -1134,7 +1291,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1134
1291
  /** Recent non-system tiddlers for the quick-note "最近" picker (newest first). */
1135
1292
  const handleRecent = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
1136
1293
  if (rejectNonRead(req, res)) return
1137
- const client = deps.getClient()
1294
+ const client = deps.getClient(req)
1138
1295
  if (client === undefined) {
1139
1296
  json(res, { ok: false, error: 'wiki service is not running' }, 503)
1140
1297
  return
@@ -1167,7 +1324,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1167
1324
  /** Full tiddler for the quick-note "最近" picker (load into the editor). */
1168
1325
  const handleGet = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
1169
1326
  if (rejectNonRead(req, res)) return
1170
- const client = deps.getClient()
1327
+ const client = deps.getClient(req)
1171
1328
  if (client === undefined) {
1172
1329
  json(res, { ok: false, error: 'wiki service is not running' }, 503)
1173
1330
  return
@@ -1228,7 +1385,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1228
1385
  */
1229
1386
  const handleSearch = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
1230
1387
  if (rejectNonRead(req, res)) return
1231
- const client = deps.getClient()
1388
+ const client = deps.getClient(req)
1232
1389
  if (client === undefined) {
1233
1390
  json(res, { ok: false, error: 'wiki service is not running' }, 503)
1234
1391
  return
@@ -1302,15 +1459,15 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1302
1459
  // This route used to SIGKILL the child directly, so clicking「重启 TW」
1303
1460
  // within ~1s of any write silently lost that write.
1304
1461
  drained = await drainThenStop({
1305
- client: deps.getClient(),
1306
- tiddlersDir: join(deps.getWikiPath(), 'tiddlers'),
1307
- stop: () => deps.server.restart(),
1462
+ client: deps.getClient(req),
1463
+ tiddlersDir: join(deps.getWikiPath(req), 'tiddlers'),
1464
+ stop: async () => { await deps.server(req)?.restart() },
1308
1465
  log: (message) => console.warn('[dsh-tiddlywiki]', message),
1309
1466
  })
1310
1467
  } finally {
1311
1468
  endMutation()
1312
1469
  }
1313
- json(res, { ok: true, status: deps.server.status().status, drained })
1470
+ json(res, { ok: true, status: deps.server(req)?.status().status, drained })
1314
1471
  } catch (err) {
1315
1472
  json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
1316
1473
  }
@@ -1329,7 +1486,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1329
1486
  return
1330
1487
  }
1331
1488
  invalidateGitStatus()
1332
- const dir = deps.getWikiPath()
1489
+ const dir = deps.getWikiPath(req)
1333
1490
  const status = async (): Promise<GitStatusViewPublic | null> => {
1334
1491
  try { return await deps.git.status(dir) } catch { return null }
1335
1492
  }
@@ -1346,24 +1503,19 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1346
1503
  return
1347
1504
  }
1348
1505
  let restarted = false
1506
+ let restartedWikis: string[] | undefined
1349
1507
  let restartError: string | undefined
1350
- if (pulled.changed === true) {
1508
+ if (pulled.changed === true && deps.restartAffected !== undefined) {
1351
1509
  try {
1352
- // DRAIN THE SYNCER FIRST (v0.19.1, data safety): a REST PUT answers 204
1353
- // while the filesystem syncer still holds the tiddler (~250ms timer).
1354
- // Restarting TW before the flush kills those writes — the restarted
1355
- // server boots from the pre-write snapshot and the note is gone, and
1356
- // the `git commit` right below cannot recover what never hit disk.
1357
- // v0.24.1: the drain lives inside `drainThenStop` (ironclad rule #1 is
1358
- // one primitive, not a per-route habit).
1359
- const drained = await drainThenStop({
1360
- client: deps.getClient(),
1361
- tiddlersDir: join(dir, 'tiddlers'),
1362
- stop: () => deps.server.restart(),
1363
- log: (message) => console.warn('[dsh-tiddlywiki]', message),
1364
- })
1365
- if (!drained) console.warn('[dsh-tiddlywiki] sync: syncer queue may not be drained before restart')
1366
- restarted = true
1510
+ // v0.28.0: restart ONLY the wikis whose content changed, and let the
1511
+ // runtime drain the syncer queue first (ironclad rule #1 lives inside
1512
+ // `WikiInstance.restart()`). Restarting "the wiki this request came
1513
+ // from" would miss the one that actually changed when several share a
1514
+ // repository — and would interrupt a wiki nothing happened to.
1515
+ const outcome = await deps.restartAffected(req, dir, pulled.changedFiles ?? [])
1516
+ restarted = outcome.restarted.length > 0
1517
+ if (outcome.restarted.length > 0) restartedWikis = outcome.restarted
1518
+ if (outcome.failed.length > 0) restartError = outcome.failed.map((item) => `${item.id}: ${item.message}`).join('; ')
1367
1519
  } catch (err) {
1368
1520
  restartError = err instanceof Error ? err.message : String(err)
1369
1521
  }
@@ -1397,6 +1549,9 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1397
1549
  pull: 'ok',
1398
1550
  ...(pulled.changed === true ? { changed: true } : {}),
1399
1551
  restarted,
1552
+ // Which knowledge bases were restarted (v0.28.0). `restarted` stays a
1553
+ // BOOLEAN for the existing client, which only shows a sentence.
1554
+ ...(restartedWikis !== undefined ? { restartedWikis } : {}),
1400
1555
  ...(restartError !== undefined ? { restartError } : {}),
1401
1556
  commit: committed.message,
1402
1557
  push: pushed.message,
@@ -1441,7 +1596,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1441
1596
  json(res, { ok: false, error: `不允许上传可执行/可脚本化的文件类型:${extension}` }, 400)
1442
1597
  return
1443
1598
  }
1444
- const filesDir = join(deps.getWikiPath(), 'files')
1599
+ const filesDir = join(deps.getWikiPath(req), 'files')
1445
1600
  await mkdir(filesDir, { recursive: true })
1446
1601
  // Collision avoidance with an ATOMIC create (v0.19.3): the old code did
1447
1602
  // `access(candidate)` then `writeFile(candidate)`, so (a) any access error
@@ -1468,7 +1623,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1468
1623
  json(res, { ok: false, error: '同名文件过多,请换一个文件名' }, 409)
1469
1624
  return
1470
1625
  }
1471
- deps.autoCommit()
1626
+ deps.autoCommit(req)
1472
1627
  invalidateGitStatus()
1473
1628
  json(res, {
1474
1629
  ok: true,
@@ -1507,7 +1662,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1507
1662
  const handleRender = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
1508
1663
  try {
1509
1664
  if (rejectCrossSiteWrite(req, res, ['POST'])) return
1510
- const client = deps.getClient()
1665
+ const client = deps.getClient(req)
1511
1666
  if (client === undefined) {
1512
1667
  json(res, { ok: false, error: 'wiki service is not running' }, 503)
1513
1668
  return
@@ -1586,8 +1741,10 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1586
1741
  // forwarded to the TW child. The set is the TiddlyWeb API surface the TW
1587
1742
  // frontend uses.
1588
1743
  if (rejectCrossSiteWrite(req, res, ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS'])) return
1589
- const client = deps.getClient()
1590
- if (client === undefined) {
1744
+ // The upstream base comes straight from the child (v0.28.0: per request, so
1745
+ // a named wiki can be proxied); the client is only a liveness proxy.
1746
+ const server = deps.server(req)
1747
+ if (server?.url === undefined) {
1591
1748
  json(res, { ok: false, error: 'wiki service is not running' }, 503)
1592
1749
  return
1593
1750
  }
@@ -1607,7 +1764,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1607
1764
  if (method === 'PUT' || method === 'DELETE' || method === 'POST') headers['x-requested-with'] = 'TiddlyWiki'
1608
1765
  const init: RequestInit = { method, headers, signal: AbortSignal.timeout(15_000) }
1609
1766
  if (method === 'PUT' || method === 'POST') init.body = await readBody(req, MAX_PROXY_BODY_BYTES)
1610
- const upstream = await fetch(`${deps.server.url}${rest}${url.search}`, init)
1767
+ const upstream = await fetch(`${server.url}${rest}${url.search}`, init)
1611
1768
  const data = await upstream.text()
1612
1769
  res.writeHead(upstream.status, {
1613
1770
  'content-type': upstream.headers.get('content-type') ?? 'application/json; charset=utf-8',
@@ -1633,17 +1790,31 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1633
1790
  // pathname only, so without it TRACE or a typo'd verb was forwarded straight
1634
1791
  // to the TW child. Same set the sibling /api proxy declares.
1635
1792
  if (rejectCrossSiteWrite(req, res, ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS'])) return
1636
- const client = deps.getClient()
1637
- if (client === undefined) {
1638
- json(res, { ok: false, error: 'wiki service is not running' }, 503)
1639
- return
1640
- }
1641
1793
  const url = new URL(req.url ?? '/', 'http://127.0.0.1')
1642
- const rest = url.pathname.replace(new RegExp(`^${TW_PROXY_PREFIX}(?=/|$)`), '') || '/'
1643
- if (isBlockedProxyPath(rest)) {
1794
+ // PER-WIKI FORM (v0.28.0): `/tw/<id>/<tw-path>`. The first segment is only
1795
+ // consumed when it names a REGISTERED wiki, so TW's own `/tw/status` and
1796
+ // `/tw/files/…` keep meaning "the default wiki's TW path" — that is exactly
1797
+ // what the reserved-id rule guarantees (a wiki id may never equal a TW root
1798
+ // segment; see RESERVED_WIKI_IDS in host/wiki-registry.ts).
1799
+ const afterPrefix = url.pathname.slice(TW_PROXY_PREFIX.length).replace(/^\//, '')
1800
+ const [firstSegment = '', ...tail] = afterPrefix.split('/')
1801
+ const named = deps.wikiIds().includes(firstSegment) ? firstSegment : undefined
1802
+ const rest = named === undefined
1803
+ ? (url.pathname.replace(new RegExp(`^${TW_PROXY_PREFIX}(?=/|$)`), '') || '/')
1804
+ : `/${tail.join('/')}`
1805
+ // The guard runs on BOTH forms: the raw path is what a crafted request
1806
+ // controls, and the stripped `rest` is what actually reaches TW.
1807
+ if (isBlockedProxyPath(url.pathname) || isBlockedProxyPath(rest)) {
1644
1808
  json(res, { ok: false, error: 'system tiddler not exposed' }, 403)
1645
1809
  return
1646
1810
  }
1811
+ // Resolve the child LAST, so a named-but-stopped wiki answers 503 instead of
1812
+ // being proxied to whoever happens to be the default.
1813
+ const server = named === undefined ? deps.server(req) : deps.serverById(named)
1814
+ if (server?.url === undefined) {
1815
+ json(res, { ok: false, error: 'wiki service is not running' }, 503)
1816
+ return
1817
+ }
1647
1818
  try {
1648
1819
  const method = (req.method ?? 'GET').toUpperCase()
1649
1820
  const headers = forwardHeaders(req.headers)
@@ -1663,7 +1834,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1663
1834
  // frontend does not send DELETE bodies, but the proxy is a generic
1664
1835
  // passthrough — drain whatever is there.
1665
1836
  else if (req.readableEnded === false && (req.headers['content-length'] !== undefined || req.headers['transfer-encoding'] !== undefined)) req.resume()
1666
- const upstream = await fetch(`${deps.server.url}${rest}${url.search}`, init)
1837
+ const upstream = await fetch(`${server.url}${rest}${url.search}`, init)
1667
1838
  const responseHeaders: Record<string, string> = {
1668
1839
  'content-type': upstream.headers.get('content-type') ?? 'application/octet-stream',
1669
1840
  'cache-control': upstream.headers.get('cache-control') ?? 'no-store',
@@ -1717,7 +1888,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1717
1888
  * deployment answers 403 instead of starting browser automation.
1718
1889
  */
1719
1890
  const guardWechat = (req: IncomingMessage, res: ServerResponse): boolean => {
1720
- const config = deps.wechatConfig()
1891
+ const config = deps.wechatConfig(req)
1721
1892
  if (!config.enabled) {
1722
1893
  json(res, { ok: false, error: 'wechat publishing is disabled' }, 403)
1723
1894
  return false
@@ -1741,7 +1912,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1741
1912
  try {
1742
1913
  if (rejectNonRead(req, res)) return
1743
1914
  if (!guardWechat(req, res)) return
1744
- const ready = await deps.wechatReady()
1915
+ const ready = await deps.wechatReady(req)
1745
1916
  json(res, {
1746
1917
  ok: true,
1747
1918
  enabled: ready.enabled,
@@ -1761,7 +1932,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1761
1932
  * overrides it for exotic setups.
1762
1933
  */
1763
1934
  const wechatDsn = (req: IncomingMessage): string => {
1764
- const configured = deps.wechatConfig().dsn
1935
+ const configured = deps.wechatConfig(req).dsn
1765
1936
  if (configured.length > 0) return configured
1766
1937
  const port = req.socket.localPort
1767
1938
  return port === undefined ? '' : `http://127.0.0.1:${port}${ROUTE_PREFIX}`
@@ -1812,7 +1983,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1812
1983
  }
1813
1984
  // Fail fast on a title that does not exist: the adapter would otherwise
1814
1985
  // spend ~10s booting the browser only to 404 in /render.
1815
- const client = deps.getClient()
1986
+ const client = deps.getClient(req)
1816
1987
  if (client !== undefined) {
1817
1988
  const tiddler = await client.get(title)
1818
1989
  if (tiddler === undefined) {
@@ -1820,8 +1991,8 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1820
1991
  return
1821
1992
  }
1822
1993
  }
1823
- const adapter = (requested ?? deps.wechatConfig().adapter) as WechatAdapter
1824
- const ready = await deps.wechatReady()
1994
+ const adapter = (requested ?? deps.wechatConfig(req).adapter) as WechatAdapter
1995
+ const ready = await deps.wechatReady(req)
1825
1996
  const adapterReady = adapter === 'publish-note-imgs' ? ready.adapters.publishNoteImgs : ready.adapters.publishNote
1826
1997
  if (!ready.opencli.ok || !adapterReady) {
1827
1998
  // `stale` (v0.23.4) = installed but outdated adapter files: the common
@@ -1903,6 +2074,8 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
1903
2074
  ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/upload`, handler: guardHandler(handleUpload) }),
1904
2075
  ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/restart`, handler: guardHandler(handleRestart) }),
1905
2076
  ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/session/summary`, handler: guardHandler(handleSessionSummary) }),
2077
+ // One path, both methods (the host webserver dispatches by pathname only).
2078
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/session/wiki`, handler: guardHandler(handleSessionWikiRoute) }),
1906
2079
  ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/sessions`, handler: guardHandler(handleAgentSessions) }),
1907
2080
  ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/modes`, handler: guardHandler(handleAgentModes) }),
1908
2081
  ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/send`, handler: guardHandler(handleAgentSend) }),