dsh-thoughtdag 0.5.17 → 0.5.19

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 (24) hide show
  1. package/README.md +13 -12
  2. package/dist-app/assets/{canonical-BYB1lzcN.js → canonical-bSxBmsm7.js} +2 -2
  3. package/dist-app/assets/{canvas-record-DEyrBPBB.js → canvas-record-qO1KINUh.js} +1 -1
  4. package/dist-app/assets/{claude-code-session-D_7zmlSg.js → claude-code-session-DkNDRQp8.js} +1 -1
  5. package/dist-app/assets/{codex-session-uM9wSh4p.js → codex-session-B2hAZ_ax.js} +1 -1
  6. package/dist-app/assets/{dsh-session-hF4KiJEe.js → dsh-session-AQk-ryCb.js} +1 -1
  7. package/dist-app/assets/{experiment-loop-BpXLtMih.js → experiment-loop-BzzzDlbH.js} +1 -1
  8. package/dist-app/assets/http-bridge-BnYAV4lB.js +1 -0
  9. package/dist-app/assets/{index-Bmw4g-fF.js → index-BH9ozg79.js} +186 -186
  10. package/dist-app/assets/{index-C3A0a4vg.js → index-BmwffA_p.js} +1 -1
  11. package/dist-app/assets/{index-Dx__HVNV.js → index-YWyx765g.js} +3 -3
  12. package/dist-app/assets/{index-BEFqbPg2.js → index-aOSCk5D-.js} +1 -1
  13. package/dist-app/assets/{pi-session-Cza2Qn3S.js → pi-session-CNcLBGJ1.js} +1 -1
  14. package/dist-app/assets/{sensitive-scan-xUG_OUN8.js → sensitive-scan-Cs2TuwIB.js} +1 -1
  15. package/dist-app/assets/{session-handoff-BMAOplpO.js → session-handoff-CLQ0ZIYD.js} +2 -2
  16. package/dist-app/assets/{shared-CIY4bfom.js → shared-LHIg0YHa.js} +1 -1
  17. package/dist-app/assets/{turndown-plugin-gfm.cjs-8Yws7MsX.js → turndown-plugin-gfm.cjs-j992pbuV.js} +1 -1
  18. package/dist-app/assets/{update-check-Bpoi6nTP.js → update-check-3zMP9HL5.js} +1 -1
  19. package/dist-app/index.html +1 -1
  20. package/lib/client.js +181 -71
  21. package/lib/index.js +27 -1
  22. package/lib/runtime/backup-write.cjs +56 -0
  23. package/package.json +1 -1
  24. package/dist-app/assets/http-bridge-CeoNZsls.js +0 -1
package/lib/client.js CHANGED
@@ -1,28 +1,45 @@
1
1
  // dsh-thoughtdag client half — the lightest possible browser shim.
2
- // It renders a floating "对话 | 思维图" switch over the harness UI and, on
3
- // "思维图", shows a full-screen SAME-ORIGIN iframe at /thoughtdag/ (the SPA
4
- // is served by the host half on the same web server — no CORS, no second
5
- // origin). The switch floats on purpose: it is there before any session is
6
- // open (0.4.18 moved it into the session header, which hid it on the empty
7
- // page; withdrawn in 0.4.19). While the canvas is up, the SPA shows the same
8
- // switch in its own top bar, next to the canvas chip, so nothing floats over
9
- // the canvas toolbar at any width: the pill hides once the SPA has booted
10
- // and comes back when the canvas closes (0.4.20). All conversation smarts live inside the ThoughtDAG app; this file
11
- // only opens the door and forwards the current session id so the canvas can
12
- // offer to mirror it.
2
+ // It puts ThoughtDAG where the harness puts its own pages: an entry in the
3
+ // sidebar's panel list (beside Plugins and Scheduled tasks) that opens the
4
+ // canvas in the main column, through the host's own `main` panel slot and
5
+ // `layout.selectPanel`. The canvas itself is a SAME-ORIGIN iframe at
6
+ // /thoughtdag/ (the SPA is served by the host half on the same web server —
7
+ // no CORS, no second origin). The iframe never lives inside the host's React
8
+ // tree: the main slot renders only the selected panel and would unmount it on
9
+ // every switch, reloading the canvas. So the panel page is a placeholder that
10
+ // fills the main column, and the iframe is a fixed layer laid exactly over
11
+ // it while the page is mounted — the host owns navigation, chrome, locale and
12
+ // theme; the canvas keeps its state across switches.
13
13
  //
14
- // Same pattern as dsh-synapse: window.__ModuleLoader__.load with a module
15
- // whose inject lists the client services it reads (sessions) and whose apply
16
- // touches the DOM.
14
+ // Hosts without the panel slot (older harnesses) get the previous door: a
15
+ // floating 对话 | 思维图 pill over the chat and a full-window overlay (0.4.19
16
+ // to 0.5.18; its placement was the subject of #55, #61, #63 and #73 — every
17
+ // spot in a stranger's chrome collides with something, which is why the
18
+ // panel entry replaces it wherever the host offers one).
19
+ //
20
+ // All conversation smarts live inside the ThoughtDAG app; this file only
21
+ // opens the door and forwards the current session id so the canvas can offer
22
+ // to mirror it. Same pattern as dsh-synapse: window.__ModuleLoader__.load
23
+ // with a module whose inject lists the client services it reads and whose
24
+ // apply touches the DOM.
17
25
 
18
26
  window.__ModuleLoader__.load({
19
27
  id: 'dsh-thoughtdag',
20
- factory: () => {
28
+ factory: (require) => {
21
29
  const module = { exports: {} }
22
30
 
23
- // uiWorkspace: the harness's directory picker (the OS dialog when the
24
- // harness runs on this machine, its in-app browser when reached remotely)
25
- module.exports.inject = ['sessions', 'uiWorkspace']
31
+ // the host's React, for the two slot components (the sidebar glyph and
32
+ // the panel page); absent on a host that lends none, which also has no
33
+ // panel slot to fill
34
+ let React = null
35
+ try { React = require('react') } catch { React = null }
36
+
37
+ // sessions: the current session for the canvas to mirror. uiWorkspace: the
38
+ // harness's directory picker (the OS dialog when the harness runs on this
39
+ // machine, its in-app browser when reached remotely). slots / locale /
40
+ // layout: the panel entry, its label in the host's language, and the
41
+ // switch back to the conversation.
42
+ module.exports.inject = ['sessions', 'uiWorkspace', 'slots', 'locale', 'layout']
26
43
  module.exports.apply = ctx => {
27
44
  const currentSession = () => {
28
45
  const snapshot = ctx.sessions.list.getSnapshot()
@@ -32,32 +49,37 @@ window.__ModuleLoader__.load({
32
49
  return session === undefined ? null : { id, title: session.displayTitle ?? null, cwd: session.cwd ?? null }
33
50
  }
34
51
 
35
- const style = document.createElement('style')
36
- // The map overlay starts below the window's top strip: the desktop host
37
- // publishes --dsh-frame-top-clearance on its root (48px under the
38
- // hiddenInset traffic lights on macOS, the caption height on Windows,
39
- // 0 in native fullscreen). In that strip the plugin draws a title band
40
- // of its own: the ThoughtDAG name, the 对话|思维图 switch and the canvas's
41
- // name, draggable like the host's own chrome rows (data-window-drag),
42
- // starting past the traffic lights (--dsh-frame-leading-clearance while
43
- // the sidebar is collapsed, 84px otherwise, since the overlay covers the
44
- // sidebar too) and stopping short of the Windows caption buttons. So
45
- // the lights and the window controls are never covered, and nothing of
46
- // the host's session header shows through (#39). A browser tab has no
47
- // clearance: no band, the overlay fills the window, the canvas keeps its
48
- // own switch.
49
- // The floating pill sits in the window's top strip, centre-right, on every platform: 12px down in a
50
- // browser tab, vertically centred in the host's own strip on the desktop (40px on Windows, 48px on
51
- // macOS). It is no-drag, so the strip's drag region never takes its clicks (#55: pinned half into the
52
- // drag region without no-drag, its upper half was swallowed; moved below the strip, it landed in the
53
- // host's session header). Centre-right keeps it clear of the host's menu on the left and the window
54
- // controls on the right.
55
- style.textContent = '.dsh-td-switch{position:fixed;z-index:120;top:12px;left:60%;display:flex;align-items:center;gap:2px;transform:translateX(-50%);-webkit-app-region:no-drag;border:1px solid #d1d5db;border-radius:999px;background:rgba(255,255,255,.96);padding:3px;backdrop-filter:blur(10px)}.dsh-td-switch button{height:28px;border:0;border-radius:999px;background:transparent;padding:0 11px;color:#6b7280;font:600 12px Inter,system-ui,sans-serif;cursor:pointer;white-space:nowrap}.dsh-td-switch button:hover{background:#f3f4f6;color:#111827}.dsh-td-switch button.active{background:#111827;color:#fff}.dsh-td-switch .dsh-td-mark{display:inline-block;width:8px;height:8px;border-radius:50%;background:#6d5dfc;box-shadow:0 -7px 0 #a99cff,0 7px 0 #f0a35a;margin:0 6px 0 8px;flex:none}.dsh-td-switch[hidden]{display:none}html[data-platform] .dsh-td-switch,html[data-windows-titlebar] .dsh-td-switch{top:max(2px,calc((var(--dsh-frame-top-clearance,40px) - 36px) / 2))}.dsh-td-bar{position:fixed;z-index:121;top:0;left:0;right:0;height:var(--dsh-frame-top-clearance,0px);display:flex;align-items:center;gap:14px;padding:0 16px;box-sizing:border-box;background:#faf9f7;border-bottom:1px solid #e7e2d9;font:500 12px Inter,system-ui,sans-serif;color:#6b7280;overflow:hidden;-webkit-app-region:drag;user-select:none}.dsh-td-bar[hidden]{display:none}html[data-platform="darwin"] .dsh-td-bar{padding-left:max(var(--dsh-frame-leading-clearance,0px),84px)}html[data-windows-titlebar] .dsh-td-bar{padding-right:calc(100% - env(titlebar-area-width,100%) + 16px)}.dsh-td-bar .dsh-td-brand{display:flex;align-items:center;gap:7px;color:#111827;font-weight:600;font-size:13px;white-space:nowrap}.dsh-td-bar .dsh-td-brand i{display:inline-block;width:8px;height:8px;border-radius:50%;background:#6d5dfc;box-shadow:0 -7px 0 #a99cff,0 7px 0 #f0a35a}.dsh-td-bar .dsh-td-switch{position:static;transform:none;-webkit-app-region:no-drag}.dsh-td-bar .dsh-td-title{min-width:0;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#6b7280}.dsh-td-overlay{position:fixed;z-index:100;inset:var(--dsh-frame-top-clearance,0px) 0 0 0;background:#faf9f7}.dsh-td-overlay[hidden]{display:none}.dsh-td-overlay iframe{display:block;width:100%;height:100%;border:0}'
56
- document.head.append(style)
52
+ // The sheet is OWNED: the host's module loader claims every untagged
53
+ // <style> for whichever plugin is materializing at that moment and
54
+ // removes it again when that plugin is replaced or reloaded; untagged,
55
+ // this sheet went with a stranger and the overlay collapsed into the
56
+ // document flow (#70). data-plugin names the owner, data-plugin-css the
57
+ // sheet; and apply reuses what an earlier run left, so nothing doubles.
58
+ const CSS_ID = 'dsh-thoughtdag/client.css'
59
+ let style = document.head.querySelector(`style[data-plugin-css="${CSS_ID}"]`)
60
+ if (!style) {
61
+ style = document.createElement('style')
62
+ style.setAttribute('data-plugin', 'dsh-thoughtdag')
63
+ style.setAttribute('data-plugin-css', CSS_ID)
64
+ document.head.append(style)
65
+ }
66
+ // Panel mode: .dsh-td-page is the placeholder the host renders in its
67
+ // main column; on macOS it pads by the window's top strip (the host's
68
+ // own entry pages do the same) so the canvas toolbar never sits under
69
+ // the traffic lights. .dsh-td-overlay is the iframe's fixed layer, laid
70
+ // over the page by place() — no inset of its own.
71
+ // Fallback mode (no panel slot): the floating pill in the window's top
72
+ // strip, the macOS title band over the strip while the canvas is up
73
+ // (#39), the overlay filling the window below the strip.
74
+ style.textContent = '.dsh-td-page{display:block;width:100%;height:100%;min-height:100%;box-sizing:border-box}html[data-platform="darwin"] .dsh-td-page{padding-top:var(--dsh-frame-top-clearance,0px)}.dsh-td-overlay{position:fixed;z-index:100;background:#faf9f7}.dsh-td-overlay[hidden]{display:none}.dsh-td-overlay iframe{display:block;width:100%;height:100%;border:0}.dsh-td-overlay.dsh-td-full{inset:var(--dsh-frame-top-clearance,0px) 0 0 0}.dsh-td-switch{position:fixed;z-index:120;top:12px;left:60%;display:flex;align-items:center;gap:2px;transform:translateX(-50%);-webkit-app-region:no-drag;border:1px solid #d1d5db;border-radius:999px;background:rgba(255,255,255,.96);padding:3px;backdrop-filter:blur(10px)}.dsh-td-switch button{height:28px;border:0;border-radius:999px;background:transparent;padding:0 11px;color:#6b7280;font:600 12px Inter,system-ui,sans-serif;cursor:pointer;white-space:nowrap}.dsh-td-switch button:hover{background:#f3f4f6;color:#111827}.dsh-td-switch button.active{background:#111827;color:#fff}.dsh-td-switch .dsh-td-mark{display:inline-block;width:8px;height:8px;border-radius:50%;background:#6d5dfc;box-shadow:0 -7px 0 #a99cff,0 7px 0 #f0a35a;margin:0 6px 0 8px;flex:none}.dsh-td-switch[hidden]{display:none}html[data-platform] .dsh-td-switch,html[data-windows-titlebar] .dsh-td-switch{top:max(2px,calc((var(--dsh-frame-top-clearance,40px) - 36px) / 2))}.dsh-td-bar{position:fixed;z-index:121;top:0;left:0;right:0;height:var(--dsh-frame-top-clearance,0px);display:flex;align-items:center;gap:14px;padding:0 16px;box-sizing:border-box;background:#faf9f7;border-bottom:1px solid #e7e2d9;font:500 12px Inter,system-ui,sans-serif;color:#6b7280;overflow:hidden;-webkit-app-region:drag;user-select:none}.dsh-td-bar[hidden]{display:none}html[data-platform="darwin"] .dsh-td-bar{padding-left:max(var(--dsh-frame-leading-clearance,0px),84px)}html[data-windows-titlebar] .dsh-td-bar{padding-right:calc(100% - env(titlebar-area-width,100%) + 16px)}.dsh-td-bar .dsh-td-brand{display:flex;align-items:center;gap:7px;color:#111827;font-weight:600;font-size:13px;white-space:nowrap}.dsh-td-bar .dsh-td-brand i{display:inline-block;width:8px;height:8px;border-radius:50%;background:#6d5dfc;box-shadow:0 -7px 0 #a99cff,0 7px 0 #f0a35a}.dsh-td-bar .dsh-td-switch{position:static;transform:none;-webkit-app-region:no-drag}.dsh-td-bar .dsh-td-title{min-width:0;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#6b7280}'
57
75
 
58
- const host = document.createElement('div')
59
- host.innerHTML = '<div class="dsh-td-switch" role="group" aria-label="view switch"><i class="dsh-td-mark" title="ThoughtDAG"></i><button type="button" data-view="dialog" class="active" aria-pressed="true">对话</button><button type="button" data-view="map" aria-pressed="false">思维图</button></div><div class="dsh-td-bar" data-window-drag hidden><span class="dsh-td-brand"><i></i>ThoughtDAG</span><div class="dsh-td-switch" role="group" aria-label="view switch"><button type="button" data-view="dialog" aria-pressed="false">对话</button><button type="button" data-view="map" class="active" aria-pressed="true">思维图</button></div><span class="dsh-td-title"></span></div><section class="dsh-td-overlay" hidden><iframe title="ThoughtDAG" data-src="/thoughtdag/"></iframe></section>'
60
- document.body.append(host)
76
+ let host = document.body.querySelector(':scope > [data-dsh-thoughtdag]')
77
+ if (!host) {
78
+ host = document.createElement('div')
79
+ host.setAttribute('data-dsh-thoughtdag', '')
80
+ document.body.append(host)
81
+ }
82
+ host.innerHTML = '<div class="dsh-td-switch" role="group" aria-label="view switch" hidden><i class="dsh-td-mark" title="ThoughtDAG"></i><button type="button" data-view="dialog" class="active" aria-pressed="true">对话</button><button type="button" data-view="map" aria-pressed="false">思维图</button></div><div class="dsh-td-bar" data-window-drag hidden><span class="dsh-td-brand"><i></i>ThoughtDAG</span><div class="dsh-td-switch" role="group" aria-label="view switch"><button type="button" data-view="dialog" aria-pressed="false">对话</button><button type="button" data-view="map" class="active" aria-pressed="true">思维图</button></div><span class="dsh-td-title"></span></div><section class="dsh-td-overlay" hidden><iframe title="ThoughtDAG" data-src="/thoughtdag/"></iframe></section>'
61
83
 
62
84
  // the plugin's version, for the canvas's update dialog and release history
63
85
  let pluginVersion = null
@@ -66,52 +88,139 @@ window.__ModuleLoader__.load({
66
88
  const switchEl = host.querySelector(':scope > .dsh-td-switch')
67
89
  const bar = host.querySelector('.dsh-td-bar')
68
90
  const barTitle = bar.querySelector('.dsh-td-title')
69
- // the desktop host marks its root; the band exists only there, and only while the strip has height (not in native fullscreen)
91
+ const dialogBtn = host.querySelector('[data-view="dialog"]')
92
+ const mapBtn = host.querySelector('[data-view="map"]')
93
+ const overlay = host.querySelector('.dsh-td-overlay')
94
+ const frame = host.querySelector('iframe')
95
+ // the desktop host marks its root; the strip is the window's top band (not in native fullscreen)
70
96
  const desktop = () => document.documentElement.dataset.platform !== undefined || document.documentElement.hasAttribute('data-windows-titlebar')
71
97
  const strip = () => parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--dsh-frame-top-clearance')) || 0
98
+ const send = (type, payload) => frame.contentWindow?.postMessage({ source: 'dsh-thoughtdag', type, ...payload }, location.origin)
99
+ const syncCurrent = () => send('td:current-session', { session: currentSession() })
100
+ // the SPA boots on first open, never while hidden: a canvas that
101
+ // measures itself inside a display:none frame fits its view to a 0×0
102
+ // box and shows nothing when revealed
103
+ const ensureSrc = () => { if (!frame.src) frame.src = frame.dataset.src + (pluginVersion ? (frame.dataset.src.includes('?') ? '&' : '?') + 'dv=' + encodeURIComponent(pluginVersion) : '') }
104
+
105
+ // ── panel mode: the host's sidebar entry and main-column page ──
106
+ const PANEL_ID = 'thoughtdag'
107
+ const NS = 'dsh-thoughtdag'
108
+ let panelMode = false
109
+ // the page element while the panel is selected (null otherwise)
110
+ let pageEl = null
111
+ let pageObserver = null
112
+ const place = () => {
113
+ if (!pageEl) return
114
+ let box = pageEl
115
+ // the page fills its column; should a host give it no height, the
116
+ // nearest ancestor with one is the column
117
+ for (let i = 0; i < 3 && box.getBoundingClientRect().height < 40 && box.parentElement; i++) box = box.parentElement
118
+ const r = box.getBoundingClientRect()
119
+ const pad = box === pageEl ? parseFloat(getComputedStyle(pageEl).paddingTop) || 0 : 0
120
+ overlay.style.left = r.left + 'px'
121
+ overlay.style.top = (r.top + pad) + 'px'
122
+ overlay.style.width = r.width + 'px'
123
+ overlay.style.height = Math.max(0, r.height - pad) + 'px'
124
+ }
125
+ const openPanel = el => {
126
+ pageEl = el
127
+ ensureSrc()
128
+ overlay.classList.remove('dsh-td-full')
129
+ overlay.hidden = false
130
+ place()
131
+ pageObserver = new ResizeObserver(place)
132
+ pageObserver.observe(el)
133
+ if (el.parentElement) pageObserver.observe(el.parentElement)
134
+ syncCurrent()
135
+ // no band: the host's own column is the canvas's edge; the SPA shows its own switch
136
+ send('td:view', { shown: true, bar: false, desktop: desktop(), panel: true })
137
+ window.setTimeout(() => { if (pageEl) { syncCurrent(); send('td:view', { shown: true, bar: false, desktop: desktop(), panel: true }) } }, 400)
138
+ }
139
+ const closePanel = () => {
140
+ pageEl = null
141
+ pageObserver?.disconnect()
142
+ pageObserver = null
143
+ overlay.hidden = true
144
+ send('td:view', { shown: false, bar: false, desktop: desktop(), panel: true })
145
+ }
146
+ // set once the host has declared the panel slots and taken our entries; a
147
+ // host that never declares them (older harnesses) gets the fallback below
148
+ let registered = false
149
+ if (React && ctx.slots && ctx.layout && ctx.locale && typeof ctx.effect === 'function') {
150
+ try {
151
+ const h = React.createElement
152
+ // the three-dot mark, as the sidebar glyph
153
+ const PanelIcon = ({ size }) => h('svg', { width: size ?? 16, height: size ?? 16, viewBox: '0 0 16 16', 'aria-hidden': 'true' },
154
+ h('circle', { cx: 8, cy: 3, r: 2.2, fill: '#a99cff' }),
155
+ h('circle', { cx: 8, cy: 8, r: 2.2, fill: '#6d5dfc' }),
156
+ h('circle', { cx: 8, cy: 13, r: 2.2, fill: '#f0a35a' }))
157
+ // the page: a placeholder the iframe layer is laid over while mounted
158
+ const PanelPage = () => {
159
+ const ref = React.useRef(null)
160
+ React.useEffect(() => {
161
+ if (ref.current) openPanel(ref.current)
162
+ return () => closePanel()
163
+ }, [])
164
+ return h('div', { ref, className: 'dsh-td-page', 'data-td-page': '' })
165
+ }
166
+ ctx.effect(() => ctx.locale.register(NS, { zh: { panel: 'ThoughtDAG' }, en: { panel: 'ThoughtDAG' } }), 'dsh-thoughtdag: dictionaries')
167
+ const t = ctx.locale.bind(NS)
168
+ ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS, inject: () => ({}) }, PanelPage))
169
+ ctx.slots.inject('sidebar.panellist', () => { const dispose = ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 30, locale: NS, label: () => t('panel') }, PanelIcon); registered = true; return dispose })
170
+ panelMode = true
171
+ } catch (error) {
172
+ console.warn('[dsh-thoughtdag] panel entry unavailable, using the floating switch:', error)
173
+ panelMode = false
174
+ }
175
+ }
176
+
177
+ // ── fallback mode: the floating pill and the full-window overlay ──
178
+ let spaReady = false
72
179
  // the band only on macOS: there the strip under the traffic lights is transparent and the host's session
73
180
  // header would show through the overlay (#39). On Windows the strip is the host's own chrome (its menu,
74
181
  // the caption buttons), opaque and above us; a band there painted over the host's menu (#55). With no
75
182
  // band the overlay starts below the chrome and the canvas shows its own switch.
76
- const barUp = () => document.documentElement.dataset.platform === 'darwin' && strip() > 0
77
- let spaReady = false
78
- const dialogBtn = host.querySelector('[data-view="dialog"]')
79
- const mapBtn = host.querySelector('[data-view="map"]')
80
- const overlay = host.querySelector('.dsh-td-overlay')
81
- const frame = host.querySelector('iframe')
82
-
183
+ const barUp = () => !panelMode && document.documentElement.dataset.platform === 'darwin' && strip() > 0
83
184
  const setView = map => {
84
185
  dialogBtn.classList.toggle('active', !map)
85
186
  dialogBtn.setAttribute('aria-pressed', String(!map))
86
187
  mapBtn.classList.toggle('active', map)
87
188
  mapBtn.setAttribute('aria-pressed', String(map))
88
189
  }
89
- const close = () => { overlay.hidden = true; bar.hidden = true; switchEl.hidden = false; setView(false); send('td:view', { shown: false, bar: false, desktop: desktop() }) }
190
+ const closeFallback = () => { overlay.hidden = true; bar.hidden = true; switchEl.hidden = false; setView(false); send('td:view', { shown: false, bar: false, desktop: desktop() }) }
90
191
  const showBand = () => { bar.hidden = !barUp(); send('td:view', { shown: true, bar: !bar.hidden, desktop: desktop() }) }
91
- window.addEventListener('resize', () => { if (!overlay.hidden) showBand() })
92
- bar.querySelector('[data-view="dialog"]').addEventListener('click', () => close())
93
- const send = (type, payload) => frame.contentWindow?.postMessage({ source: 'dsh-thoughtdag', type, ...payload }, location.origin)
94
-
95
- const syncCurrent = () => {
96
- const session = currentSession()
97
- send('td:current-session', { session })
98
- }
99
-
100
- mapBtn.addEventListener('click', () => {
192
+ const openFallback = () => {
193
+ overlay.classList.add('dsh-td-full')
194
+ overlay.style.left = overlay.style.top = overlay.style.width = overlay.style.height = ''
101
195
  overlay.hidden = false
102
196
  setView(true)
103
197
  bar.hidden = !barUp()
104
198
  if (spaReady || !bar.hidden) switchEl.hidden = true
105
- // the SPA boots on first open, never while hidden: a canvas that
106
- // measures itself inside a display:none frame fits its view to a 0×0
107
- // box and shows nothing when revealed
108
- if (!frame.src) frame.src = frame.dataset.src + (pluginVersion ? (frame.dataset.src.includes('?') ? '&' : '?') + 'dv=' + encodeURIComponent(pluginVersion) : '')
199
+ ensureSrc()
109
200
  syncCurrent()
110
201
  showBand()
111
202
  // let the SPA boot, then re-sync so its listener is ready
112
203
  window.setTimeout(() => { syncCurrent(); showBand() }, 400)
113
- })
114
- dialogBtn.addEventListener('click', close)
204
+ }
205
+ let fallbackOn = false
206
+ const enableFallback = () => {
207
+ if (fallbackOn) return
208
+ fallbackOn = true
209
+ panelMode = false
210
+ switchEl.hidden = false
211
+ window.addEventListener('resize', () => { if (!overlay.hidden) showBand() })
212
+ bar.querySelector('[data-view="dialog"]').addEventListener('click', closeFallback)
213
+ mapBtn.addEventListener('click', openFallback)
214
+ dialogBtn.addEventListener('click', closeFallback)
215
+ }
216
+ if (!panelMode) enableFallback()
217
+ // the slot system takes registrations only once the host declares the
218
+ // slot; a host that has not declared sidebar.panellist within a moment
219
+ // of our apply has no panel list at all, so the floating switch returns
220
+ else window.setTimeout(() => { if (!registered) { console.warn('[dsh-thoughtdag] no sidebar panel list on this host, using the floating switch'); enableFallback() } }, 2500)
221
+
222
+ // back to the conversation: the host's own navigation in panel mode, the overlay's close otherwise
223
+ const close = () => { if (panelMode) { try { ctx.layout.selectPanel(null) } catch { closePanel() } } else closeFallback() }
115
224
 
116
225
  window.addEventListener('message', event => {
117
226
  if (event.origin !== location.origin || event.data?.source !== 'dsh-thoughtdag') return
@@ -132,12 +241,13 @@ window.__ModuleLoader__.load({
132
241
  return
133
242
  }
134
243
  if (event.data.type === 'td:request-current') {
135
- // the SPA has booted and shows its own switch: the pill steps aside
244
+ // the SPA has booted and shows its own switch: in fallback mode the pill steps aside
136
245
  spaReady = true
137
- if (!overlay.hidden) { switchEl.hidden = true; showBand() }
246
+ if (!panelMode && !overlay.hidden) { switchEl.hidden = true; showBand() }
247
+ if (panelMode && pageEl) send('td:view', { shown: true, bar: false, desktop: desktop(), panel: true })
138
248
  return syncCurrent()
139
249
  }
140
- // the canvas names its project: the band shows it
250
+ // the canvas names its project: the band shows it (fallback mode only)
141
251
  if (event.data.type === 'td:title') { barTitle.textContent = typeof event.data.name === 'string' ? event.data.name : ''; return }
142
252
  // the canvas forked or continued a session: stage it and go back to the
143
253
  // chat, which now shows exactly the context the canvas produced
package/lib/index.js CHANGED
@@ -81,6 +81,10 @@ export const inject = ['webServer', 'sessions', 'sessionController', 'agents', '
81
81
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
82
82
  const require = createRequire(import.meta.url)
83
83
  const APP_DIR = resolve(__dirname, '../dist-app')
84
+ // the path rules for the canvas's local backup (#66); the route below shells
85
+ // this, and it is required HERE because the handler needs it in scope — inside
86
+ // agentsHttp() it would be a function-local binding the handler cannot see
87
+ const { writeBackup, BackupRequestError } = require(resolve(__dirname, 'runtime', 'backup-write.cjs'))
84
88
 
85
89
  // The plugin's own version travels into the canvas's URL (?dv=), so the
86
90
  // update dialog and the release history know which release runs here, as
@@ -422,7 +426,13 @@ function contentOf(body) {
422
426
  throw new HttpError(400, 'body needs text or blocks')
423
427
  }
424
428
 
425
- const SOURCE = { kind: 'plugin', plugin: 'dsh-thoughtdag' }
429
+ // The attribution on every message this plugin injects. The kind is this
430
+ // producer's OWN: the harness's v4 session format refuses the bare shared
431
+ // literal 'plugin' (its validator throws "format v4 message requires a
432
+ // producer-owned source kind" before the Session adopts the event, so the
433
+ // targeted turn dies and nothing is persisted, #69); only the v3→v4 migration
434
+ // ever rewrote it. scripts/test-inject-source.mjs holds this line to that rule.
435
+ const SOURCE = { kind: 'plugin:dsh-thoughtdag', plugin: 'dsh-thoughtdag' }
426
436
 
427
437
  // ── the other agents' session files on this machine ────────────────────
428
438
 
@@ -1217,6 +1227,22 @@ export async function apply(ctx, config) {
1217
1227
  const body = req.method === 'POST' ? await readJson(req, MAX_WRITE_BODY_BYTES).catch(() => null) : null
1218
1228
  if (await agentsHttp().handle(req, res, path, body)) return
1219
1229
  }
1230
+ // ── the canvas's local backup, written by THIS host (#66) ──
1231
+ // The File System Access API needs a directory handle, and Chromium's own
1232
+ // picker never returns inside the harness frame on Windows, so the canvas
1233
+ // cannot write the file itself there. It keeps the absolute path the
1234
+ // harness's own picker handed it and asks this endpoint to write instead.
1235
+ // The path rules live in runtime/backup-write.cjs so they can be tested
1236
+ // without a harness: scripts/test-backup-write.mjs.
1237
+ if (path === '/backup' && req.method === 'POST') {
1238
+ const body = await readJson(req, MAX_WRITE_BODY_BYTES).catch(() => null)
1239
+ try {
1240
+ return sendJson(res, 200, { file: await writeBackup(body) })
1241
+ } catch (error) {
1242
+ if (error instanceof BackupRequestError) return sendJson(res, 400, { error: error.message })
1243
+ return sendJson(res, 500, { error: 'could not write the backup: ' + (error instanceof Error ? error.message : String(error)) })
1244
+ }
1245
+ }
1220
1246
  // ── model connection (the SPA's proxy protocol, on the harness's providers) ──
1221
1247
  if (path === '/models' && req.method === 'GET') return sendJson(res, 200, await modelsPayload(ctx, registeredProviders))
1222
1248
  // the canvas registers its browser-stored interfaces at boot (and after a change): the list answers with them in it
@@ -0,0 +1,56 @@
1
+ // The canvas's local backup, written by the HOST (#66).
2
+ //
3
+ // The File System Access API needs a directory handle, and Chromium's own
4
+ // picker never returns inside the harness frame on Windows — the canvas cannot
5
+ // write the file itself there. So the canvas keeps the absolute path the
6
+ // harness's own picker handed it and asks the host to write instead.
7
+ //
8
+ // This lives beside the agent runtimes rather than inline in the plugin, so the
9
+ // path rules can be tested without a harness: scripts/test-backup-write.mjs.
10
+ // Pure Node, no harness context.
11
+ 'use strict';
12
+ const { mkdir, writeFile } = require('node:fs/promises');
13
+ const { dirname, isAbsolute, resolve, sep } = require('node:path');
14
+
15
+ /** An error the endpoint should report as 400 rather than 500. */
16
+ class BackupRequestError extends Error {}
17
+
18
+ /**
19
+ * Write one canvas backup under `dir`.
20
+ *
21
+ * @param {{ dir: unknown, name: unknown, json: unknown }} body the request body
22
+ * @returns {Promise<string>} the absolute file written
23
+ * @throws {BackupRequestError} when the request itself is not writable
24
+ */
25
+ async function writeBackup(body) {
26
+ const dir = typeof body?.dir === 'string' ? body.dir : '';
27
+ const name = typeof body?.name === 'string' ? body.name : '';
28
+ const json = typeof body?.json === 'string' ? body.json : null;
29
+ // an absolute path is the whole safety property: a relative one would resolve
30
+ // against the plugin's cwd, which the canvas cannot know.
31
+ // isAbsolute, NOT resolve(dir).startsWith(sep) — on Windows a drive path
32
+ // starts with "C:" and would never match the separator, so that test refuses
33
+ // every real request there while passing on POSIX.
34
+ if (!dir || !isAbsolute(dir) || !name || json === null) {
35
+ throw new BackupRequestError('dir must be an absolute path; name and json are required');
36
+ }
37
+ // `name` is a RELATIVE path the canvas chose (native canvases at the folder
38
+ // root, session mirrors under cli/), so it is sanitized segment by segment
39
+ const parts = name.replace(/\\/g, '/').split('/').filter((p) => p && p !== '.' && p !== '..');
40
+ if (parts.length === 0) throw new BackupRequestError('name must have at least one path segment');
41
+ const last = parts[parts.length - 1].replace(/[<>:"|?*\u0000-\u001f]/g, '_').slice(0, 120);
42
+ if (!last) throw new BackupRequestError('name resolves to an empty file name');
43
+ const target = resolve(dir, ...parts.slice(0, -1), last);
44
+ // Belt and braces, and it has to agree with the gate above: compare against
45
+ // resolve(dir) — NOT resolve(dir) + sep, which would reject a Windows path
46
+ // for the same reason the startsWith(sep) test did.
47
+ const inside = resolve(dir);
48
+ if (target !== inside && !target.startsWith(inside.endsWith(sep) ? inside : inside + sep)) {
49
+ throw new BackupRequestError('name escapes the backup directory');
50
+ }
51
+ await mkdir(dirname(target), { recursive: true });
52
+ await writeFile(target, json, 'utf8');
53
+ return target;
54
+ }
55
+
56
+ module.exports = { writeBackup, BackupRequestError };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-thoughtdag",
3
- "version": "0.5.17",
3
+ "version": "0.5.19",
4
4
  "description": "ThoughtDAG for DeepSeek Harness: turn the current DSH session into an editable thought graph on an infinite canvas. Source, releases and issues: github.com/chenxiachan/thoughtdag",
5
5
  "author": "Xia Chen",
6
6
  "repository": {
@@ -1 +0,0 @@
1
- import{I as f}from"./index-Bmw4g-fF.js";async function y(l){if(window.desktopAgents)return!0;const r=l.replace(/\/+$/,"");let i;try{const e=await fetch(r+"/agents/available",{credentials:"same-origin"});if(!e.ok)return!1;i=await e.json()}catch{return!1}const s=async(e,t)=>{const n=await fetch(r+e,{method:"POST",headers:{"Content-Type":"application/json"},credentials:"same-origin",body:JSON.stringify(t??{})});if(!n.ok)throw new Error(`${e}: ${n.status}`);return n.json()},c=[];let o=null;const d=()=>{o||(o=new EventSource(r+"/agents/events"),o.onmessage=e=>{try{const t=JSON.parse(e.data);for(const n of c)n(t)}catch{}})},w=f,g={nativePicker:w},p=()=>new Promise(e=>{if(!w)return e(null);const t=`cwd-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`,n=u=>{const a=u.data;u.origin!==window.location.origin||a?.source!=="dsh-thoughtdag"||a.type!=="td:picked-cwd"||a.requestId!==t||(window.removeEventListener("message",n),a.unsupported&&(g.nativePicker=!1),e(typeof a.path=="string"&&a.path?a.path:null))};window.addEventListener("message",n),window.parent.postMessage({source:"dsh-thoughtdag",type:"td:pick-cwd",requestId:t},window.location.origin)});return window.desktopAgents={capabilities:g,available:async()=>i,models:async e=>(await fetch(r+"/agents/models"+(e?`?runtime=${encodeURIComponent(e)}`:""),{credentials:"same-origin"})).json(),run:async e=>(d(),(await s("/agents/run",e)).runId),abort:async e=>(await s("/agents/abort",{runId:e})).ok,answer:async(e,t,n)=>(await s("/agents/answer",{runId:e,requestId:t,response:n})).ok,workspace:async e=>(await s("/agents/workspace",{canvasId:e})).dir,pickCwd:()=>p(),guardWrite:async(e,t)=>(await s("/agents/guard",{cwd:e,config:t})).ok,writeMaterials:async(e,t)=>s("/agents/materials",{cwd:e,files:t}),onEvent:e=>{c.push(e),d()}},!0}export{y as installAgentsHttpBridge};