dsh-tiddlywiki 0.5.0 → 0.7.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-tiddlywiki",
3
- "description": "TiddlyWiki 5 as the DSH persistent knowledge base: tiddlywiki_* agent tools (search with filters/recent/tags/batch/rename + git sync & conflict resolve), a full TiddlyWiki editor embedded in the GUI center column, a quick-note card with draft auto-save & recent-notes picker behind a single knowledge FAB, and git-based sync with auto-commit. Mounts via the official dsh plugin system — no DSH source changes.",
4
- "version": "0.5.0",
3
+ "description": "TiddlyWiki 5 as the DSH persistent knowledge base: tiddlywiki_* agent tools (search with filters/recent/tags/batch/rename + git sync & conflict resolve), a full TiddlyWiki editor embedded in the GUI center column (same-origin proxy, works over Tailscale/LAN/domain), a quick-note card with draft auto-save & recent-notes picker behind a single knowledge FAB, and git-based sync with auto-commit. Mounts via the official dsh plugin system — no DSH source changes.",
4
+ "version": "0.7.2",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "dsh",
@@ -634,7 +634,11 @@ export function createNoteWidget(): NoteWidgetHandle {
634
634
  }
635
635
  clearDraft()
636
636
  hideDraftBanner()
637
- openEditorPopup(`${payload.twUrl}#${encodeURIComponent(payload.draftTitle)}`, payload.title ?? title)
637
+ // twUrl is the same-origin proxy path (e.g. /dsh-tiddlywiki/tw/);
638
+ // resolve it against this page's origin so the popup works from any
639
+ // host/domain DSH is reached on (loopback, LAN, Tailscale, HTTPS).
640
+ const popupUrl = `${new URL(payload.twUrl, location.origin).href}#${encodeURIComponent(payload.draftTitle)}`
641
+ openEditorPopup(popupUrl, payload.title ?? title)
638
642
  toast(`已在弹出窗口打开「${payload.title ?? title}」编辑器`)
639
643
  } catch (err) {
640
644
  toast(`打开失败:${err instanceof Error ? err.message : String(err)}`)
@@ -10,10 +10,13 @@
10
10
  * Toggling rides the shared PanelState; cross-plugin exclusivity rides the
11
11
  * `dsh-panel-activate` event (same protocol as dsh-taskboard).
12
12
  *
13
- * The iframe points DIRECTLY at the TW service (http://127.0.0.1:<port>),
14
- * bypassing reverse-proxy sensitivity (design doc R1 — proxy is only a
15
- * fallback). The panel reads /status first and only sets iframe.src when the
16
- * service is actually running.
13
+ * The iframe points at the SAME-ORIGIN TW proxy (`<origin>/dsh-tiddlywiki/tw/`,
14
+ * remote-access mode R1): the browser only talks to the DSH origin — which it
15
+ * already reaches over loopback, LAN, Tailscale, a domain or HTTPS — and DSH
16
+ * proxies to the loopback TW child. The panel reads /status first and only
17
+ * sets iframe.src when the service is actually running. Because the proxy URL
18
+ * is origin-relative and port-independent, a TW restart never reloads the
19
+ * iframe, so an in-progress edit keeps its unsaved state.
17
20
  *
18
21
  * @module dsh-tiddlywiki/client/panel
19
22
  */
@@ -76,6 +79,8 @@ interface StatusPayload {
76
79
  ok?: boolean
77
80
  status: string
78
81
  url?: string
82
+ /** Same-origin TW proxy path (e.g. /dsh-tiddlywiki/tw/); the iframe base. */
83
+ twProxy?: string
79
84
  wikiPath?: string
80
85
  error?: string
81
86
  note?: { tag?: string }
@@ -232,9 +237,18 @@ export function mountPanel(state: PanelState): () => void {
232
237
  showError('无法访问 /dsh-tiddlywiki/status')
233
238
  return
234
239
  }
235
- if (payload.status === 'running' && typeof payload.url === 'string') {
240
+ if (payload.status === 'running') {
236
241
  refreshAttempts = 0
237
- showFrame(payload.url)
242
+ // Same-origin proxy URL: build from the page's own origin so it works no
243
+ // matter which host/domain the user reached DSH on. Fall back to the
244
+ // legacy loopback `url` for older servers that do not send twProxy.
245
+ if (typeof payload.twProxy === 'string') {
246
+ showFrame(new URL(payload.twProxy, window.location.origin).href)
247
+ } else if (typeof payload.url === 'string') {
248
+ showFrame(payload.url)
249
+ } else {
250
+ showError('服务未返回编辑器地址')
251
+ }
238
252
  return
239
253
  }
240
254
  if (payload.status === 'starting') {
@@ -27,6 +27,13 @@ export interface PluginConfigShape {
27
27
  showPanelStatus?: boolean
28
28
  /** 是否在界面右下角显示「同步」悬浮按钮(默认 true)。 */
29
29
  showSyncButton?: boolean
30
+ /**
31
+ * 一键发送给 Agent(TW 笔记 → DSH 会话注入)。
32
+ * `enabled: false` 时路由 403、TW 端按钮点击提示未启用;`token` 非空时
33
+ * 路由要求请求头 `x-send-to-agent-token` 与之匹配;`endpoint` 可覆盖
34
+ * TW 端默认的 `location.origin + /dsh-tiddlywiki`。
35
+ */
36
+ sendToAgent?: { enabled?: boolean; endpoint?: string; token?: string }
30
37
  }
31
38
  uiLanguage?: string
32
39
  [key: string]: unknown
@@ -7,20 +7,24 @@
7
7
  * | /dsh-tiddlywiki/note | POST | quick-note → independent tiddler |
8
8
  * | /dsh-tiddlywiki/restart | POST | one-click retry/restart of the TW child |
9
9
  * | /dsh-tiddlywiki/api/* | any | passthrough to the TW service (JSON) |
10
+ * | /dsh-tiddlywiki/tw/* | any | SAME-ORIGIN TW proxy (index + files + TiddlyWeb API) |
10
11
  *
11
- * Matching is exact-over-prefix, so the exact routes win and the `/api`
12
- * prefix catches the rest. Client calls are same-origin (the DSH web server),
13
- * so no CORS is involved.
12
+ * Matching is exact-over-prefix, so the exact routes win and the `/api` /
13
+ * `/tw` prefixes catch the rest. Client calls are same-origin (the DSH web
14
+ * server), so no CORS is involved. The `/tw` proxy is the remote-access
15
+ * bridge: it serves the ENTIRE TW frontend to the browser through the DSH
16
+ * origin (see TW_PROXY_PATH in wiki.ts), so the embedded editor works no
17
+ * matter which host/domain the user reaches DSH on.
14
18
  *
15
19
  * @module dsh-tiddlywiki/host/routes
16
20
  */
17
- import type { IncomingMessage, ServerResponse } from 'node:http'
21
+ import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http'
18
22
  import { mkdir, writeFile, access } from 'node:fs/promises'
19
23
  import { basename, extname, join } from 'node:path'
20
24
  import type { TiddlyWebClient } from './tw-api.ts'
21
25
  import type { WikiServer } from './wiki.ts'
22
26
  import type { GitFace } from './git.ts'
23
- import { PATH_PREFIX } from './wiki.ts'
27
+ import { PATH_PREFIX, TW_PROXY_PREFIX, TW_PROXY_PATH } from './wiki.ts'
24
28
 
25
29
  export const ROUTE_PREFIX = PATH_PREFIX
26
30
 
@@ -43,6 +47,32 @@ export interface WebServerFace {
43
47
  register(route: { kind: 'exact' | 'prefix'; path: string; handler: (req: IncomingMessage, res: ServerResponse) => void }): () => void
44
48
  }
45
49
 
50
+ /**
51
+ * Structural face over the DSH `sessionController` service (a subset of
52
+ * dsh-api-session-controller). Only the methods the agent-send routes need are
53
+ * declared; the runtime instance is a real Service, never inspected data.
54
+ */
55
+ export interface SessionControllerFace {
56
+ prompt(
57
+ request: { requestId: string; sessionId: string; mode: 'queue' | 'steer'; content: Array<{ type: 'text'; text: string }> },
58
+ signal: AbortSignal,
59
+ ): Promise<{ accepted: boolean }>
60
+ list(
61
+ request: { cursor?: string },
62
+ signal: AbortSignal,
63
+ ): Promise<{
64
+ items: Array<{
65
+ sessionId: string
66
+ updatedAt?: number
67
+ running?: boolean
68
+ blank?: boolean
69
+ parentSessionId?: string
70
+ cwd?: string
71
+ }>
72
+ }>
73
+ create(request: { cwd?: string; workspaceId?: string }): Promise<{ sessionId: string }>
74
+ }
75
+
46
76
  /** Effective UI visibility flags returned by /status (mirror index.ts). */
47
77
  export interface UiDefaultsPublic {
48
78
  showQuickNote: boolean
@@ -58,6 +88,13 @@ export interface RouteDeps {
58
88
  noteDefaults: () => { tag: string }
59
89
  uiDefaults: () => UiDefaultsPublic
60
90
  getWikiPath: () => string
91
+ /** Optional DSH sessionController service (agent-send routes only); resolved
92
+ * lazily per request because it may register after webServer appears. */
93
+ getSessionController: () => SessionControllerFace | undefined
94
+ /** Whether the one-click send-to-agent feature is enabled (config switch). */
95
+ sendToAgentEnabled: () => boolean
96
+ /** Optional shared token that must match `x-send-to-agent-token` when set. */
97
+ sendToAgentToken: () => string
61
98
  }
62
99
 
63
100
  function readBody(req: IncomingMessage, limit = MAX_BODY_BYTES): Promise<string> {
@@ -97,6 +134,26 @@ function readBodyBuffer(req: IncomingMessage, limit = MAX_UPLOAD_BYTES): Promise
97
134
  })
98
135
  }
99
136
 
137
+ /** Header names forwarded to the upstream TW service by the proxy routes. */
138
+ const FORWARD_HEADER_NAMES = [
139
+ 'accept', 'accept-encoding', 'content-type', 'cookie', 'authorization',
140
+ 'if-none-match', 'if-modified-since', 'origin', 'referer', 'user-agent',
141
+ ] as const
142
+
143
+ /** Copy a safe, string-valued subset of the request headers upstream. */
144
+ function forwardHeaders(headers: IncomingHttpHeaders): Record<string, string> {
145
+ const out: Record<string, string> = {}
146
+ for (const name of FORWARD_HEADER_NAMES) {
147
+ // Index through the string index signature so known header names (typed
148
+ // `string`) do not hide the `string[]` repeat case via their specific
149
+ // property declarations.
150
+ const value: string | string[] | undefined = headers[name as string]
151
+ if (typeof value === 'string') out[name] = value
152
+ else if (Array.isArray(value) && value.length > 0) out[name] = value.join(', ')
153
+ }
154
+ return out
155
+ }
156
+
100
157
  /**
101
158
  * Sanitize an uploaded filename into a safe bare name (no path separators,
102
159
  * no `..`, no control characters). Returns '' when nothing usable remains.
@@ -202,7 +259,116 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
202
259
  } catch {
203
260
  gitSummary = null
204
261
  }
205
- json(res, { ok: true, ...view, git: gitSummary, note: { tag: deps.noteDefaults().tag }, ui: deps.uiDefaults() })
262
+ json(res, { ok: true, ...view, twProxy: TW_PROXY_PATH, git: gitSummary, note: { tag: deps.noteDefaults().tag }, ui: deps.uiDefaults() })
263
+ }
264
+
265
+ /**
266
+ * GET /dsh-tiddlywiki/agent/sessions — visible ordinary sessions for the TW
267
+ * one-click picker (excludes subagent sessions, activity-descending).
268
+ */
269
+ const handleAgentSessions = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
270
+ try {
271
+ const sc = deps.getSessionController()
272
+ if (sc === undefined) {
273
+ json(res, { ok: false, error: 'session service unavailable' }, 503)
274
+ return
275
+ }
276
+ const list = await sc.list({}, AbortSignal.timeout(10_000))
277
+ const items = (list.items ?? [])
278
+ .filter((s) => s.parentSessionId === undefined)
279
+ .map((s) => ({
280
+ sessionId: s.sessionId,
281
+ cwd: s.cwd ?? null,
282
+ running: !!s.running,
283
+ blank: !!s.blank,
284
+ updatedAt: s.updatedAt ?? 0,
285
+ }))
286
+ .sort((a, b) => b.updatedAt - a.updatedAt)
287
+ json(res, { ok: true, items })
288
+ } catch (err) {
289
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
290
+ }
291
+ }
292
+
293
+ /**
294
+ * POST /dsh-tiddlywiki/agent/send — deliver a note to one agent session as a
295
+ * queued user message (sessionController.prompt, the same API the GUI chat
296
+ * input uses). Guards: feature switch, optional shared token, body shape.
297
+ */
298
+ const handleAgentSend = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
299
+ try {
300
+ if (!deps.sendToAgentEnabled()) {
301
+ json(res, { ok: false, error: 'send-to-agent is disabled' }, 403)
302
+ return
303
+ }
304
+ const token = deps.sendToAgentToken().trim()
305
+ if (token.length > 0) {
306
+ const got = req.headers['x-send-to-agent-token']
307
+ const value = typeof got === 'string' ? got : Array.isArray(got) ? got[0] ?? '' : ''
308
+ if (value !== token) {
309
+ json(res, { ok: false, error: 'unauthorized' }, 401)
310
+ return
311
+ }
312
+ }
313
+ const body = JSON.parse(await readBody(req)) as { sessionId?: unknown; text?: unknown }
314
+ const sessionId = typeof body.sessionId === 'string' && body.sessionId.trim().length > 0 ? body.sessionId.trim() : ''
315
+ const text = typeof body.text === 'string' && body.text.trim().length > 0 ? body.text.trim() : ''
316
+ if (sessionId.length === 0 || text.length === 0) {
317
+ json(res, { ok: false, error: 'sessionId and text are required' }, 400)
318
+ return
319
+ }
320
+ const sc = deps.getSessionController()
321
+ if (sc === undefined) {
322
+ json(res, { ok: false, error: 'session service unavailable' }, 503)
323
+ return
324
+ }
325
+ const requestId = `tw-send-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
326
+ await sc.prompt(
327
+ { requestId, sessionId, mode: 'queue', content: [{ type: 'text', text }] },
328
+ AbortSignal.timeout(20_000),
329
+ )
330
+ json(res, { ok: true, requestId, sessionId })
331
+ } catch (err) {
332
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
333
+ }
334
+ }
335
+
336
+ /**
337
+ * POST /dsh-tiddlywiki/agent/create — create (or adopt) one ordinary session,
338
+ * optionally inside a workspace path. The picker uses it for "new workspace /
339
+ * new session"; the created session's cwd becomes its workspace. The directory
340
+ * is materialised so a brand-new workspace actually exists on disk.
341
+ */
342
+ const handleAgentCreate = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
343
+ try {
344
+ if (!deps.sendToAgentEnabled()) {
345
+ json(res, { ok: false, error: 'send-to-agent is disabled' }, 403)
346
+ return
347
+ }
348
+ const token = deps.sendToAgentToken().trim()
349
+ if (token.length > 0) {
350
+ const got = req.headers['x-send-to-agent-token']
351
+ const value = typeof got === 'string' ? got : Array.isArray(got) ? got[0] ?? '' : ''
352
+ if (value !== token) {
353
+ json(res, { ok: false, error: 'unauthorized' }, 401)
354
+ return
355
+ }
356
+ }
357
+ const body = JSON.parse(await readBody(req)) as { cwd?: unknown }
358
+ const cwd = typeof body.cwd === 'string' ? body.cwd.trim() : ''
359
+ const sc = deps.getSessionController()
360
+ if (sc === undefined) {
361
+ json(res, { ok: false, error: 'session service unavailable' }, 503)
362
+ return
363
+ }
364
+ if (cwd.length > 0) {
365
+ await mkdir(cwd, { recursive: true })
366
+ }
367
+ const created = await sc.create({ cwd: cwd.length > 0 ? cwd : undefined })
368
+ json(res, { ok: true, sessionId: created.sessionId, cwd: cwd || null })
369
+ } catch (err) {
370
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
371
+ }
206
372
  }
207
373
 
208
374
  const handleNote = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
@@ -241,7 +407,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
241
407
  const text = typeof body.text === 'string' ? body.text : ''
242
408
  const result = await openInTwEditor(client, title, text, tags)
243
409
  deps.autoCommit()
244
- json(res, { ok: true, ...result, twUrl: deps.server.url })
410
+ json(res, { ok: true, ...result, twUrl: TW_PROXY_PATH })
245
411
  } catch (err) {
246
412
  json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
247
413
  }
@@ -426,7 +592,9 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
426
592
  ok: true,
427
593
  name: candidate,
428
594
  path: `files/${candidate}`,
429
- url: `/files/${encodeURIComponent(candidate)}`,
595
+ // Same-origin proxy URL: the embedded TW editor resolves image links
596
+ // against the DSH origin, so a root-absolute `/files/...` would miss.
597
+ url: `${TW_PROXY_PATH}files/${encodeURIComponent(candidate)}`,
430
598
  size: buf.length,
431
599
  type: req.headers['content-type'] ?? 'application/octet-stream',
432
600
  })
@@ -465,6 +633,51 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
465
633
  }
466
634
  }
467
635
 
636
+ /**
637
+ * SAME-ORIGIN proxy /dsh-tiddlywiki/tw/<rest> → TW root /<rest>. Serves the
638
+ * ENTIRE TW frontend (index HTML, /files/*, the TiddlyWeb API) to the
639
+ * browser through the DSH origin, so the embedded editor works from any
640
+ * host/domain the user reaches DSH on (loopback, LAN, Tailscale, domain,
641
+ * HTTPS). The browser never talks to the loopback TW child directly; DSH
642
+ * does, on the same machine. Binary responses are buffered losslessly
643
+ * (arrayBuffer) — unlike the /api JSON proxy, this route must never .text().
644
+ */
645
+ const handleTwProxy = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
646
+ const client = deps.getClient()
647
+ if (client === undefined) {
648
+ json(res, { ok: false, error: 'wiki service is not running' }, 503)
649
+ return
650
+ }
651
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
652
+ const rest = url.pathname.replace(new RegExp(`^${TW_PROXY_PREFIX}(?=/|$)`), '') || '/'
653
+ try {
654
+ const method = (req.method ?? 'GET').toUpperCase()
655
+ const headers = forwardHeaders(req.headers)
656
+ // TW's CSRF gate requires X-Requested-With on writes; forward it through.
657
+ if (method === 'PUT' || method === 'DELETE' || method === 'POST') headers['x-requested-with'] = 'TiddlyWiki'
658
+ const init: RequestInit = { method, headers, signal: AbortSignal.timeout(30_000) }
659
+ if (method === 'PUT' || method === 'POST') init.body = await readBodyBuffer(req, MAX_UPLOAD_BYTES)
660
+ const upstream = await fetch(`${deps.server.url}${rest}${url.search}`, init)
661
+ const data = Buffer.from(await upstream.arrayBuffer())
662
+ const responseHeaders: Record<string, string> = {
663
+ 'content-type': upstream.headers.get('content-type') ?? 'application/octet-stream',
664
+ 'cache-control': upstream.headers.get('cache-control') ?? 'no-store',
665
+ }
666
+ for (const name of ['etag', 'last-modified', 'content-disposition']) {
667
+ const value = upstream.headers.get(name)
668
+ if (value !== null) responseHeaders[name] = value
669
+ }
670
+ res.writeHead(upstream.status, responseHeaders)
671
+ res.end(data)
672
+ } catch (err) {
673
+ if (res.headersSent) {
674
+ res.end()
675
+ return
676
+ }
677
+ json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 502)
678
+ }
679
+ }
680
+
468
681
  const disposers = [
469
682
  ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/status`, handler: (req, res) => { void handleStatus(req, res) } }),
470
683
  ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/note`, handler: (req, res) => { void handleNote(req, res) } }),
@@ -475,7 +688,11 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
475
688
  ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/sync`, handler: (req, res) => { void handleSync(req, res) } }),
476
689
  ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/upload`, handler: (req, res) => { void handleUpload(req, res) } }),
477
690
  ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/restart`, handler: (req, res) => { void handleRestart(req, res) } }),
691
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/sessions`, handler: (req, res) => { void handleAgentSessions(req, res) } }),
692
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/send`, handler: (req, res) => { void handleAgentSend(req, res) } }),
693
+ ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/create`, handler: (req, res) => { void handleAgentCreate(req, res) } }),
478
694
  ctx.webServer.register({ kind: 'prefix', path: `${ROUTE_PREFIX}/api`, handler: (req, res) => { void handleApiProxy(req, res) } }),
695
+ ctx.webServer.register({ kind: 'prefix', path: `${TW_PROXY_PREFIX}`, handler: (req, res) => { void handleTwProxy(req, res) } }),
479
696
  ]
480
697
  return () => {
481
698
  for (const dispose of disposers) dispose()
package/src/host/wiki.ts CHANGED
@@ -30,6 +30,18 @@ import type { Readable } from 'node:stream'
30
30
  /** The DSH webserver route prefix (NOT a TW path-prefix; see module header). */
31
31
  export const PATH_PREFIX = '/dsh-tiddlywiki'
32
32
 
33
+ /**
34
+ * Same-origin TW proxy route on the DSH webserver (remote-access mode, R1).
35
+ * The browser only ever talks to the DSH origin — which it already reaches
36
+ * over loopback, LAN, Tailscale, a domain or HTTPS — and DSH proxies to the
37
+ * loopback TW child. TW's frontend is pointed at this prefix via the
38
+ * `$:/config/tiddlyweb/host` tiddler so every API call stays same-origin.
39
+ */
40
+ export const TW_PROXY_PREFIX = `${PATH_PREFIX}/tw`
41
+
42
+ /** The proxy base path (trailing slash) handed to browsers / TW's frontend. */
43
+ export const TW_PROXY_PATH = `${TW_PROXY_PREFIX}/`
44
+
33
45
  /** How long to wait for the wiki to answer /status. */
34
46
  const READY_TIMEOUT_MS = 20_000
35
47
 
package/src/index.ts CHANGED
@@ -22,13 +22,13 @@ import { watch, type FSWatcher } from 'node:fs'
22
22
  import { writeFile } from 'node:fs/promises'
23
23
  import { join } from 'node:path'
24
24
  import { AutoCommitter, GitFace } from './host/git.ts'
25
- import { registerRoutes, type WebServerFace } from './host/routes.ts'
25
+ import { registerRoutes, type SessionControllerFace, type WebServerFace } from './host/routes.ts'
26
26
  import { ConfigStore, deepMerge, type PluginConfigShape } from './host/config.ts'
27
27
  import { registerAdminRoutes, ensureLanguage, resolveTwRoot, type AdminDeps } from './host/admin.ts'
28
28
  import { seedDocNote, DOC_NOTE_TITLE } from './host/seed-notes.ts'
29
29
  import { TiddlyWebClient } from './host/tw-api.ts'
30
30
  import { registerTiddlywikiTools, type ToolsDeps } from './host/tools.ts'
31
- import { PATH_PREFIX, WikiServer, type WikiServerOptions } from './host/wiki.ts'
31
+ import { PATH_PREFIX, TW_PROXY_PATH, TW_PROXY_PREFIX, WikiServer, type WikiServerOptions } from './host/wiki.ts'
32
32
  import { dshHomePath, defineTool } from './sdk.ts'
33
33
 
34
34
  /** Cordis plugin name (also the client loader id / profile row id). */
@@ -38,7 +38,7 @@ export const name = 'dsh-tiddlywiki'
38
38
  export const inject = ['tools', 'systemPrompt']
39
39
 
40
40
  /** Re-exports for the headless selftest and future consumers. */
41
- export { AutoCommitter, GitFace, PATH_PREFIX, TiddlyWebClient, WikiServer, dshHomePath, defineTool }
41
+ export { AutoCommitter, GitFace, PATH_PREFIX, TW_PROXY_PATH, TW_PROXY_PREFIX, TiddlyWebClient, WikiServer, dshHomePath, defineTool }
42
42
  export { ConfigStore, deepMerge } from './host/config.ts'
43
43
  export { openInTwEditor, registerRoutes } from './host/routes.ts'
44
44
  export { registerAdminRoutes, resolveTwRoot, readWikiInfo, writeWikiInfo, bundledCatalog, ensureLanguage, normalizeThemes } from './host/admin.ts'
@@ -76,7 +76,7 @@ interface ResolvedConfig {
76
76
  port: number
77
77
  git: { autoCommit: boolean; debounceMs: number; remote: string; branch: string }
78
78
  note: { tag: string }
79
- ui: { showQuickNote: boolean; showPanelStatus: boolean; showSyncButton: boolean }
79
+ ui: { showQuickNote: boolean; showPanelStatus: boolean; showSyncButton: boolean; sendToAgent: { enabled: boolean } }
80
80
  auth: { username?: string; password?: string }
81
81
  }
82
82
 
@@ -86,10 +86,35 @@ const DEFAULTS: ResolvedConfig = {
86
86
  port: 0,
87
87
  git: { autoCommit: true, debounceMs: 60_000, remote: '', branch: 'main' },
88
88
  note: { tag: 'inbox' },
89
- ui: { showQuickNote: true, showPanelStatus: true, showSyncButton: true },
89
+ ui: { showQuickNote: true, showPanelStatus: true, showSyncButton: true, sendToAgent: { enabled: true } },
90
90
  auth: { username: '', password: '' },
91
91
  }
92
92
 
93
+ /** Config tiddler steering TW's frontend API base (tiddlywebadaptor). */
94
+ export const TW_WEB_HOST_TIDDLER = '$:/config/tiddlyweb/host'
95
+ /** The legacy default value this plugin replaces with the same-origin proxy. */
96
+ const TW_WEB_HOST_DEFAULT = '$protocol$//$host$/'
97
+
98
+ /**
99
+ * Point TW's frontend at the same-origin DSH proxy (remote-access mode, R1).
100
+ * The tiddlywebadaptor builds every API URL from $:/config/tiddlyweb/host; its
101
+ * default `$protocol$//$host$/` resolves to the iframe's origin ROOT, which
102
+ * 404s whenever the browser is not on the same machine as TW. Written only
103
+ * when the tiddler is missing or still the legacy default — a user override is
104
+ * honored (e.g. someone who really does expose TW on a dedicated origin).
105
+ */
106
+ export async function ensureTwWebHost(client: TiddlyWebClient | undefined): Promise<void> {
107
+ if (client === undefined) return
108
+ let current: string | undefined
109
+ try {
110
+ current = (await client.get(TW_WEB_HOST_TIDDLER))?.text
111
+ } catch {
112
+ current = undefined
113
+ }
114
+ if (current !== undefined && current.trim() !== TW_WEB_HOST_DEFAULT) return
115
+ await client.put({ title: TW_WEB_HOST_TIDDLER, text: TW_PROXY_PATH, type: 'text/plain', tags: [] })
116
+ }
117
+
93
118
  /** Expand $VAR / ${VAR} / %VAR% from process.env (config uses $DSH_HOME). */
94
119
  function expandEnvPath(input: string): string {
95
120
  return input
@@ -282,6 +307,13 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
282
307
  try {
283
308
  await server.start()
284
309
  await configStore.load(client())
310
+ // Point TW's frontend at the same-origin DSH proxy (remote-access mode):
311
+ // runs before git bootstrap so the config tiddler joins the first commit.
312
+ try {
313
+ await ensureTwWebHost(client())
314
+ } catch (err) {
315
+ console.warn('[dsh-tiddlywiki] ensuring $:/config/tiddlyweb/host:', err)
316
+ }
285
317
  // Seeds (run after the effective config is loaded):
286
318
  // - doc note: ONE-SHOT — a fresh wiki gets the plugin guide; deleting
287
319
  // it afterwards survives restarts (see seedDocNote).
@@ -321,6 +353,11 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
321
353
  // Routes + settings-panel admin surface (lazy webServer).
322
354
  ctx.inject(['webServer'], (webCtx: HostCtx) => {
323
355
  const ws = (webCtx as unknown as { webServer: WebServerFace }).webServer
356
+ // sessionController is a core host service; read it LAZILY per request (via
357
+ // the plugin root ctx) because it may be registered after webServer, and the
358
+ // agent routes must work whenever a request actually arrives.
359
+ const getSessionController = (): SessionControllerFace | undefined =>
360
+ ctx.get('sessionController') as SessionControllerFace | undefined
324
361
  const disposeRoutes = registerRoutes({ webServer: ws }, {
325
362
  server,
326
363
  getClient: client,
@@ -329,6 +366,12 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
329
366
  noteDefaults: () => ({ tag: effectiveNoteTag() }),
330
367
  uiDefaults: () => effectiveUi(),
331
368
  getWikiPath: () => wikiPath,
369
+ getSessionController,
370
+ sendToAgentEnabled: () => eff().ui?.sendToAgent?.enabled !== false,
371
+ sendToAgentToken: () => {
372
+ const token = eff().ui?.sendToAgent?.token
373
+ return typeof token === 'string' ? token : ''
374
+ },
332
375
  })
333
376
  const adminDeps: AdminDeps = {
334
377
  server,