dsh-thoughtdag 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +92 -0
  3. package/cordis.patch.yml +11 -0
  4. package/dist-app/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  5. package/dist-app/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  6. package/dist-app/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  7. package/dist-app/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  8. package/dist-app/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  9. package/dist-app/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  10. package/dist-app/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  11. package/dist-app/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  12. package/dist-app/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  13. package/dist-app/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  14. package/dist-app/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  15. package/dist-app/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  16. package/dist-app/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  17. package/dist-app/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  18. package/dist-app/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  19. package/dist-app/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  20. package/dist-app/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  21. package/dist-app/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  22. package/dist-app/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  23. package/dist-app/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  24. package/dist-app/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  25. package/dist-app/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  26. package/dist-app/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  27. package/dist-app/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  28. package/dist-app/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  29. package/dist-app/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  30. package/dist-app/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  31. package/dist-app/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  32. package/dist-app/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  33. package/dist-app/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  34. package/dist-app/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  35. package/dist-app/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  36. package/dist-app/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  37. package/dist-app/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  38. package/dist-app/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  39. package/dist-app/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  40. package/dist-app/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  41. package/dist-app/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  42. package/dist-app/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  43. package/dist-app/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  44. package/dist-app/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  45. package/dist-app/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  46. package/dist-app/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  47. package/dist-app/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  48. package/dist-app/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  49. package/dist-app/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  50. package/dist-app/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  51. package/dist-app/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  52. package/dist-app/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  53. package/dist-app/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  54. package/dist-app/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  55. package/dist-app/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  56. package/dist-app/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  57. package/dist-app/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  58. package/dist-app/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  59. package/dist-app/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  60. package/dist-app/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  61. package/dist-app/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  62. package/dist-app/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  63. package/dist-app/assets/canonical-DbPqsMYz.js +2 -0
  64. package/dist-app/assets/canvas-record-C1A8ExlM.js +1 -0
  65. package/dist-app/assets/claude-code-session-DQRgWy00.js +7 -0
  66. package/dist-app/assets/codex-session--KVj3_vb.js +12 -0
  67. package/dist-app/assets/dsh-session-6OCSm4h5.js +6 -0
  68. package/dist-app/assets/experiment-loop-BfxITu2e.js +2 -0
  69. package/dist-app/assets/index-6m02Pb4Q.js +761 -0
  70. package/dist-app/assets/index-BH-U0zVI.js +105 -0
  71. package/dist-app/assets/index-BL39OXsY.js +221 -0
  72. package/dist-app/assets/index-BeoRn2gJ.js +2 -0
  73. package/dist-app/assets/index-D-BTv0n0.js +3 -0
  74. package/dist-app/assets/index-DEHbIrRr.css +1 -0
  75. package/dist-app/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  76. package/dist-app/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  77. package/dist-app/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  78. package/dist-app/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  79. package/dist-app/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  80. package/dist-app/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  81. package/dist-app/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  82. package/dist-app/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  83. package/dist-app/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  84. package/dist-app/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  85. package/dist-app/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  86. package/dist-app/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  87. package/dist-app/assets/live-mirror-BElcCk_a.js +2 -0
  88. package/dist-app/assets/pdf-gh-BrLFn.js +12 -0
  89. package/dist-app/assets/pdf.worker.min-C_APr3k7.js +1 -0
  90. package/dist-app/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
  91. package/dist-app/assets/purify.es-Cz4mVeUR.js +3 -0
  92. package/dist-app/assets/sensitive-scan-L0rFzufE.js +7 -0
  93. package/dist-app/assets/session-handoff-CGrowwZp.js +2 -0
  94. package/dist-app/assets/shared-CURSZnXt.js +10 -0
  95. package/dist-app/assets/turndown-plugin-gfm.cjs-p3SpZgsj.js +22 -0
  96. package/dist-app/assets/turndown.browser.es-kLzRgxYD.js +61 -0
  97. package/dist-app/assets/update-check-D5daO3Cz.js +1 -0
  98. package/dist-app/favicon.svg +10 -0
  99. package/dist-app/index.html +31 -0
  100. package/dist-app/logo-social.png +0 -0
  101. package/dist-app/logo-social.svg +52 -0
  102. package/dist-app/og-twitter.jpg +0 -0
  103. package/dist-app/og.jpg +0 -0
  104. package/dist-app/robots.txt +4 -0
  105. package/dist-app/sitemap.xml +6 -0
  106. package/dist-app/tutorial/map-en.gif +0 -0
  107. package/dist-app/tutorial/map-zh.gif +0 -0
  108. package/dist-app/tutorial/prune-en.gif +0 -0
  109. package/dist-app/tutorial/prune-zh.gif +0 -0
  110. package/dist-app/tutorial/reading-en.gif +0 -0
  111. package/dist-app/tutorial/reading-zh.gif +0 -0
  112. package/dist-app/tutorial/ref-en.gif +0 -0
  113. package/dist-app/tutorial/ref-zh.gif +0 -0
  114. package/lib/client.js +84 -0
  115. package/lib/index.js +845 -0
  116. package/package.json +107 -0
package/lib/index.js ADDED
@@ -0,0 +1,845 @@
1
+ // dsh-thoughtdag host half — a plain Cordis plugin that mounts the
2
+ // ThoughtDAG SPA under a prefix on the EXISTING DSH web server (no second
3
+ // process, no second port) and bridges DSH sessions to the canvas.
4
+ //
5
+ // Layout of this file, mirroring dsh-synapse:
6
+ // - the static ThoughtDAG build lives in ../dist-app (a Vite build with
7
+ // base = the mount prefix, so every asset reference is already
8
+ // /<prefix>/... and resolves through the routes registered below)
9
+ // - /<prefix>/ the SPA shell (index.html)
10
+ // - /<prefix>/<asset...> static assets (prefix route, path-traversal safe)
11
+ // - /<prefix>/api/... the session bridge (Host-header fenced)
12
+ //
13
+ // The bridge's read direction feeds the ThoughtDAG dsh-session adapter:
14
+ // the adapter parses the same event JSONL a real ~/.dsh session log holds,
15
+ // so the bridge serves a session's durable events as that text.
16
+ //
17
+ // The write direction is how an edited canvas becomes the model's next
18
+ // context — through DSH's own primitives, never by touching a log:
19
+ // POST /sessions/<id>/fork { afterTurn | atSeq } → a child session that
20
+ // inherits the prefix up to that turn boundary
21
+ // (sessionController.fork: the UI sees it too)
22
+ // POST /sessions/<id>/inject { text | blocks } → model-facing context
23
+ // for the next step (agent.inject: no wake,
24
+ // shown in the transcript as injected context)
25
+ // POST /sessions/<id>/followup { text | blocks, mode } → a user prompt that
26
+ // wakes the agent (sessionController.prompt)
27
+ // GET /sessions/<id>/turns turn boundaries with their seqs, so the
28
+ // canvas can name a fork point by turn
29
+ //
30
+ // The other agents on this machine: the host is a local Node process, so it
31
+ // can read what the desktop shell reads — Claude Code's ~/.claude/projects
32
+ // and Codex's ~/.codex/sessions — and serve them with the desktop bridge's
33
+ // own primitives, so Session Atlas inside the harness sees all three:
34
+ // GET /roots the two file roots (key, path, exists)
35
+ // GET /roots/<key>/list every .jsonl under a root (rel, size, mtime)
36
+ // GET /roots/<key>/head?rel&bytes the first bytes of one file, as text
37
+ // GET /roots/<key>/read?rel one whole file
38
+ // GET /roots/<key>/range?rel&start&length a line-aligned byte window
39
+ // Everything the model then sees is on the child's log, in DSH's own
40
+ // event vocabulary: switching back to the chat shows exactly that.
41
+ //
42
+ // The model connection: inside the harness ThoughtDAG has no proxy of its
43
+ // own, so this host also answers the SPA's proxy protocol with the harness's
44
+ // providers and credentials (the SPA is built with VITE_API_BASE=/thoughtdag):
45
+ // GET /models the harness's model catalog in the SPA's list shape
46
+ // (vision from each adapter's declared input modalities)
47
+ // POST /stream one model call, streamed as the SPA's SSE frames; images
48
+ // enter the attachment store and ride the last user message
49
+ // POST /claude the same call, whole answer as JSON
50
+ // POST /fetch-url the SPA's link snapshot, through the harness's bounded fetcher
51
+ // These are canvas-native calls (summaries, condensing, a canvas that is
52
+ // not a mirrored session) — they run on the harness's models but do not
53
+ // enter any session log; a mirrored session's turns go through /followup.
54
+ //
55
+ // One entry in that catalog is not a model: "harness/agent". Picking it
56
+ // sends the question INTO the harness — a fresh session per call, the
57
+ // canvas's wired context injected first, the harness running its own agent
58
+ // loop with tools — and streams the agent's text, reasoning and tool calls
59
+ // back as the same SSE frames. The session stays: the Chat lists it, the
60
+ // atlas can open it, and the canvas gets its id in the first frame.
61
+
62
+ import { randomUUID } from 'node:crypto'
63
+ import { open, readFile, readdir, stat } from 'node:fs/promises'
64
+ import { extname, join, normalize, resolve } from 'node:path'
65
+ import os from 'node:os'
66
+ import { fileURLToPath } from 'node:url'
67
+ import { zstdDecompressSync } from 'node:zlib'
68
+
69
+ export const name = 'thoughtdag'
70
+ export const inject = ['webServer', 'sessions', 'sessionController', 'agents', 'llm', 'attachments', 'web']
71
+
72
+ const __dirname = fileURLToPath(new URL('.', import.meta.url))
73
+ const APP_DIR = resolve(__dirname, '../dist-app')
74
+
75
+ const MAX_BODY_BYTES = 32 * 1024
76
+ // a compiled canvas context can be long; the write endpoints take up to this
77
+ const MAX_WRITE_BODY_BYTES = 4 * 1024 * 1024
78
+ // a model call carries the whole context the canvas compiled
79
+ const MAX_CALL_BODY_BYTES = 24 * 1024 * 1024
80
+ const MIME = {
81
+ '.html': 'text/html; charset=utf-8',
82
+ '.js': 'text/javascript; charset=utf-8',
83
+ '.mjs': 'text/javascript; charset=utf-8',
84
+ '.css': 'text/css; charset=utf-8',
85
+ '.svg': 'image/svg+xml',
86
+ '.png': 'image/png',
87
+ '.jpg': 'image/jpeg',
88
+ '.jpeg': 'image/jpeg',
89
+ '.gif': 'image/gif',
90
+ '.webp': 'image/webp',
91
+ '.json': 'application/json; charset=utf-8',
92
+ '.woff': 'font/woff',
93
+ '.woff2': 'font/woff2',
94
+ '.ttf': 'font/ttf',
95
+ '.txt': 'text/plain; charset=utf-8',
96
+ '.xml': 'application/xml',
97
+ '.ico': 'image/x-icon',
98
+ }
99
+
100
+ /** Owns an error + an HTTP status; rendered as a JSON body. */
101
+ class HttpError extends Error {
102
+ constructor(status, message) {
103
+ super(message)
104
+ this.status = status
105
+ }
106
+ }
107
+
108
+ async function readJson(req, limit = MAX_BODY_BYTES) {
109
+ const chunks = []
110
+ let length = 0
111
+ for await (const chunk of req) {
112
+ length += chunk.length
113
+ if (length > limit) throw new HttpError(413, 'request body too large')
114
+ chunks.push(chunk)
115
+ }
116
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { throw new HttpError(400, 'request body is not valid JSON') }
117
+ }
118
+
119
+ function sendJson(res, status, body) {
120
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
121
+ res.end(JSON.stringify(body))
122
+ }
123
+
124
+ function sendFile(res, contentType, body) {
125
+ res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-store' })
126
+ res.end(body)
127
+ }
128
+
129
+ /** Static asset path under APP_DIR, or null when it escapes (traversal). */
130
+ function assetPath(rel) {
131
+ const normalized = normalize(rel)
132
+ const abs = resolve(APP_DIR, normalized)
133
+ if (abs !== APP_DIR && !abs.startsWith(APP_DIR + '/')) return null
134
+ return abs
135
+ }
136
+
137
+ /** Every event of a live session, inherited prefix included — the Session
138
+ * class exposes a snapshot read, not a raw array. */
139
+ function allEvents(session) {
140
+ if (typeof session.snapshotEvents === 'function') return session.snapshotEvents()
141
+ return session.events ?? []
142
+ }
143
+
144
+ /** Session list the bridge exposes: identity + a cheap title, never the log. */
145
+ function sessionSummary(session) {
146
+ const header = session.header ?? {}
147
+ const meta = header.meta ?? {}
148
+ return {
149
+ id: session.id,
150
+ title: session.displayTitle ?? null,
151
+ cwd: meta.cwd ?? null,
152
+ parentSession: header.parentSession ?? undefined,
153
+ createdAt: header.createdAt ?? null,
154
+ firstLiveSeq: session.firstLiveSeq ?? null,
155
+ // the log's current length: a poller compares it to know the session moved
156
+ seq: typeof session.seq === 'number' ? session.seq : null,
157
+ }
158
+ }
159
+
160
+ /** Serve one session's durable event log as JSONL text (the input dialect of
161
+ * ThoughtDAG's dsh-session adapter). Sequenced events are serialized in log
162
+ * order; the exact wire shape mirrors the on-disk session.jsonl.zstd rows
163
+ * (a type/session header line first), so the canvas imports a live DSH
164
+ * session exactly like a file on disk. */
165
+ function sessionToJsonl(session) {
166
+ const lines = []
167
+ const header = session.header ?? {}
168
+ const hdr = { type: 'session', version: 0, id: session.id }
169
+ if (header.createdAt !== undefined) hdr.createdAt = header.createdAt
170
+ const meta = header.meta
171
+ if (meta?.cwd !== undefined) hdr.cwd = meta.cwd
172
+ // fork lineage, exactly as the on-disk header carries it: who the parent
173
+ // is and how many leading events are inherited (a reader of the log tells
174
+ // the child's own turns from the prefix by seq >= seedLength)
175
+ if (header.parentSession !== undefined) hdr.parentSession = header.parentSession
176
+ if (typeof session.inheritedEventCount === 'number' && session.inheritedEventCount > 0) hdr.seedLength = session.inheritedEventCount
177
+ lines.push(JSON.stringify(hdr))
178
+ for (const event of allEvents(session)) {
179
+ lines.push(JSON.stringify(event))
180
+ }
181
+ return lines.join('\n')
182
+ }
183
+
184
+ // ── durable-session bridge (disk) ──────────────────────────────────────
185
+ // DSH persists each session as session.jsonl.zstd under
186
+ // $DSH_HOME/sessions/<encoded-cwd>/<session-id>/. The zstd log is a
187
+ // CONCATENATION of independently-encoded frames (one per durable batch
188
+ // append); Node's one-shot zstdDecompressSync only decodes the FIRST frame,
189
+ // so frames are located structurally and decoded one by one (the same
190
+ // container walk the DSH persistence backend performs). A final torn frame
191
+ // (live tail) is dropped; the next read picks it up whole.
192
+
193
+ const ZSTD_MAGIC = 4247762216 // 0xFD2FB528 LE
194
+ function scanZstdFrames(buffer) {
195
+ const frames = []
196
+ let offset = 0
197
+ while (offset < buffer.length) {
198
+ const start = offset
199
+ if (buffer.length - offset < 4) break
200
+ if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) return { frames }
201
+ offset += 4
202
+ if (offset === buffer.length) break
203
+ const descriptor = buffer.readUInt8(offset)
204
+ offset += 1
205
+ const contentSizeFlag = descriptor >>> 6
206
+ const singleSegment = (descriptor & 32) !== 0
207
+ const checksum = (descriptor & 4) !== 0
208
+ const dictionaryFlag = descriptor & 3
209
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
210
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
211
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
212
+ if (buffer.length - offset < remainingHeaderBytes) break
213
+ offset += remainingHeaderBytes
214
+ for (;;) {
215
+ if (buffer.length - offset < 3) return { frames }
216
+ const blockHeader = buffer.readUIntLE(offset, 3)
217
+ offset += 3
218
+ const lastBlock = (blockHeader & 1) !== 0
219
+ const blockType = (blockHeader >>> 1) & 3
220
+ const blockSize = blockHeader >>> 3
221
+ const payloadBytes = blockType === 1 ? 1 : blockSize
222
+ if (buffer.length - offset < payloadBytes) return { frames }
223
+ offset += payloadBytes
224
+ if (lastBlock) break
225
+ }
226
+ if (checksum) {
227
+ if (buffer.length - offset < 4) return { frames }
228
+ offset += 4
229
+ }
230
+ frames.push({ start, end: offset })
231
+ }
232
+ return { frames }
233
+ }
234
+
235
+ /** Decode a concatenated-frame zstd buffer to UTF-8 text ('' when corrupt). */
236
+ function decompressZstdToText(raw) {
237
+ try {
238
+ const { frames } = scanZstdFrames(raw)
239
+ if (frames.length === 0) return ''
240
+ const parts = []
241
+ for (const { start, end } of frames) parts.push(zstdDecompressSync(raw.subarray(start, end)))
242
+ return Buffer.concat(parts).toString('utf8')
243
+ } catch { return '' }
244
+ }
245
+
246
+ /** Walk $DSH_HOME/sessions for session.jsonl.zstd files (2 levels deep). */
247
+ async function findSessionFiles(dshHome) {
248
+ const root = join(dshHome, 'sessions')
249
+ const out = []
250
+ let entries
251
+ try { entries = await readdir(root, { withFileTypes: true }) } catch { return out }
252
+ for (const ws of entries) {
253
+ if (!ws.isDirectory()) continue
254
+ let sessions
255
+ try { sessions = await readdir(join(root, ws.name), { withFileTypes: true }) } catch { continue }
256
+ for (const s of sessions) {
257
+ if (!s.isDirectory()) continue
258
+ const log = join(root, ws.name, s.name, 'session.jsonl.zstd')
259
+ try {
260
+ const st = await stat(log)
261
+ out.push({ dir: ws.name, sessionDir: s.name, log, size: st.size, mtime: st.mtimeMs })
262
+ } catch { /* no log yet */ }
263
+ }
264
+ }
265
+ return out
266
+ }
267
+
268
+ /** Cheap title from the FIRST zstd frame only (bounded read): the session
269
+ * header names the id+cwd; session/title arrives right after in live logs.
270
+ * Falls back to the session id when the frame boundary cuts early. */
271
+ const HEAD_READ_BYTES = 256 * 1024 // covers the first frames: header + title + opening turns
272
+ async function sessionTitleFromHead(logPath) {
273
+ try {
274
+ const fh = await open(logPath, 'r')
275
+ let buf
276
+ try {
277
+ const st = await fh.stat()
278
+ const want = Math.min(st.size, HEAD_READ_BYTES)
279
+ const b = Buffer.alloc(want)
280
+ const { bytesRead } = await fh.read(b, 0, want, 0)
281
+ buf = b.subarray(0, bytesRead)
282
+ } finally { await fh.close() }
283
+ const text = decompressZstdToText(buf)
284
+ let title = null
285
+ let id = null
286
+ let cwd = null
287
+ for (const raw of text.split('\n')) {
288
+ try {
289
+ const o = JSON.parse(raw)
290
+ if (o.type === 'session') {
291
+ if (typeof o.id === 'string') id = o.id
292
+ if (typeof o.cwd === 'string') cwd = o.cwd
293
+ }
294
+ if (o.type === 'session/title' && typeof o.data?.title === 'string') { title = o.data.title; break }
295
+ } catch { /* partial tail */ }
296
+ }
297
+ return { title, id, cwd }
298
+ } catch { return { title: null, id: null } }
299
+ }
300
+
301
+ // ── write direction helpers ────────────────────────────────────────────
302
+
303
+ /** The live session for an id, across the store's method spellings. */
304
+ const liveSession = (ctx, id) => (typeof ctx.sessions.get === 'function' ? ctx.sessions.get(id) : ctx.sessions.sessionOf?.(id)) ?? undefined
305
+
306
+ /** Turn boundaries of an event list: where each turn starts and ends (seq),
307
+ * and the id of the message a person actually sent in it. A fork can only
308
+ * cut at an endSeq (DSH rejects a boundary inside an open turn). */
309
+ function turnsOf(events) {
310
+ const turns = new Map()
311
+ for (const e of events) {
312
+ if (!e || typeof e.type !== 'string') continue
313
+ const n = e.data?.turn
314
+ if (e.type === 'turn/start' && typeof n === 'number') turns.set(n, { turn: n, startSeq: e.seq, endSeq: null, userMessageId: null })
315
+ else if (e.type === 'turn/end' && typeof n === 'number' && turns.has(n)) turns.get(n).endSeq = e.seq
316
+ else if (e.type === 'user/message' && (e.data?.source?.kind ?? 'user') === 'user') {
317
+ const openTurn = [...turns.values()].reverse().find(t => t.endSeq === null)
318
+ if (openTurn && openTurn.userMessageId === null) openTurn.userMessageId = e.data?.id ?? null
319
+ }
320
+ }
321
+ return [...turns.values()]
322
+ }
323
+
324
+ /** Events of a session by id: the live store first, else the durable log. */
325
+ async function eventsOfSession(ctx, id) {
326
+ const live = liveSession(ctx, id)
327
+ if (live !== undefined) return allEvents(live)
328
+ const dshHome = process.env.DSH_HOME || join(os.homedir(), '.dsh')
329
+ const files = await findSessionFiles(dshHome)
330
+ const hit = files.find(f => f.sessionDir === id)
331
+ if (!hit) return null
332
+ const raw = await readFile(hit.log).catch(() => null)
333
+ if (!raw) return null
334
+ const events = []
335
+ for (const line of decompressZstdToText(raw).split('\n')) {
336
+ if (!line) continue
337
+ try { events.push(JSON.parse(line)) } catch { /* torn tail */ }
338
+ }
339
+ return events
340
+ }
341
+
342
+ /** Model-facing content from a request body: `blocks` (text blocks only for
343
+ * now — images arrive through the attachment store in a later step) or a
344
+ * plain `text`. Empty content is a 400, never a silent no-op. */
345
+ function contentOf(body) {
346
+ if (Array.isArray(body?.blocks)) {
347
+ const blocks = body.blocks.filter(b => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0).map(b => ({ type: 'text', text: b.text }))
348
+ if (blocks.length === 0) throw new HttpError(400, 'blocks: no text block with content')
349
+ if (blocks.length !== body.blocks.length) throw new HttpError(400, 'blocks: only { type: "text", text } is accepted here')
350
+ return blocks
351
+ }
352
+ if (typeof body?.text === 'string' && body.text.length > 0) return [{ type: 'text', text: body.text }]
353
+ throw new HttpError(400, 'body needs text or blocks')
354
+ }
355
+
356
+ const SOURCE = { kind: 'plugin', plugin: 'dsh-thoughtdag' }
357
+
358
+ // ── the other agents' session files on this machine ────────────────────
359
+
360
+ const FILE_ROOTS = {
361
+ 'claude-projects': join(os.homedir(), '.claude', 'projects'),
362
+ 'codex-sessions': join(os.homedir(), '.codex', 'sessions'),
363
+ }
364
+ const FILE_READ_MAX = 256 * 1024 * 1024
365
+
366
+ /** An absolute path inside a root, or a 400/404 — rel never escapes. */
367
+ function fileInRoot(rootKey, rel) {
368
+ const root = FILE_ROOTS[rootKey]
369
+ if (!root) throw new HttpError(404, 'no such root')
370
+ if (typeof rel !== 'string' || !rel) throw new HttpError(400, 'rel required')
371
+ const abs = resolve(root, rel)
372
+ if (abs !== root && !abs.startsWith(root + '/')) throw new HttpError(400, 'rel escapes its root')
373
+ return abs
374
+ }
375
+
376
+ /** Every session file under a root, two-to-five levels deep like the desktop's walk. */
377
+ async function listRoot(rootKey) {
378
+ const root = FILE_ROOTS[rootKey]
379
+ if (!root) throw new HttpError(404, 'no such root')
380
+ const out = []
381
+ const walk = async (dir, depth) => {
382
+ if (depth > 5) return
383
+ let entries
384
+ try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
385
+ for (const ent of entries) {
386
+ const p = join(dir, ent.name)
387
+ if (ent.isDirectory()) await walk(p, depth + 1)
388
+ else if (ent.isFile() && ent.name.endsWith('.jsonl')) {
389
+ try { const st = await stat(p); out.push({ rel: p.slice(root.length + 1), size: st.size, mtime: st.mtimeMs }) } catch { /* raced */ }
390
+ }
391
+ }
392
+ }
393
+ await walk(root, 0)
394
+ return out
395
+ }
396
+
397
+ /** The first `bytes` of a file, as text (the desktop bridge's head). */
398
+ async function headOfFile(abs, bytes) {
399
+ const n = Math.min(Math.max(1024, bytes | 0), 524288)
400
+ const fh = await open(abs, 'r')
401
+ try { const buf = Buffer.alloc(n); const { bytesRead } = await fh.read(buf, 0, n, 0); return buf.subarray(0, bytesRead).toString('utf8') } finally { await fh.close() }
402
+ }
403
+
404
+ /** A byte window cut on a line boundary (the desktop bridge's read-range). */
405
+ async function rangeOfFile(abs, start, length) {
406
+ const fh = await open(abs, 'r')
407
+ try {
408
+ const from = Math.max(0, Number(start) || 0)
409
+ const want = Math.min(Math.max(65536, Number(length) || 0), 32 * 1024 * 1024)
410
+ const total = (await fh.stat()).size
411
+ const size = Math.min(want, Math.max(0, total - from))
412
+ if (size === 0) return { text: '', nextStart: from, eof: true }
413
+ const b = Buffer.alloc(size)
414
+ const { bytesRead } = await fh.read(b, 0, size, from)
415
+ let slice = b.subarray(0, bytesRead)
416
+ const eof = from + size >= total
417
+ if (!eof) {
418
+ const lastNl = slice.lastIndexOf(0x0a)
419
+ if (lastNl >= 0) slice = slice.subarray(0, lastNl + 1)
420
+ else return { text: '', nextStart: from + size, eof: false }
421
+ }
422
+ return { text: slice.toString('utf8'), nextStart: from + slice.length, eof }
423
+ } finally { await fh.close() }
424
+ }
425
+
426
+ // ── model connection helpers ───────────────────────────────────────────
427
+
428
+ /** The SPA names a harness model as "<provider>/<model>" (the model id itself
429
+ * may contain slashes, so the split is at the first one). */
430
+ function splitModelId(id) {
431
+ if (typeof id !== 'string') return null
432
+ const i = id.indexOf('/')
433
+ if (i <= 0 || i === id.length - 1) return null
434
+ return { provider: id.slice(0, i), model: id.slice(i + 1) }
435
+ }
436
+
437
+ /** The harness's catalog in the SPA's list shape. No model is marked as
438
+ * seeing images: a model is marked vision when its adapter declares image
439
+ * input; those images then enter the attachment store and ride the call. */
440
+ const AGENT_MODEL = 'harness/agent'
441
+
442
+ /** Which of a provider's models take images: the adapter's declared input
443
+ * modalities; a name that says "vision" only when the adapter says nothing. */
444
+ async function visionIdsOf(ctx, providerId, catalogModels) {
445
+ const out = new Set()
446
+ try {
447
+ for (const m of await ctx.llm.listModels(providerId)) if ((m.inputModalities ?? []).includes('image')) out.add(m.id)
448
+ if (out.size > 0) return out
449
+ } catch { /* provider not routable right now */ }
450
+ for (const m of catalogModels) if (/vision/i.test(m.id) || /vision/i.test(m.name ?? '')) out.add(m.id)
451
+ return out
452
+ }
453
+
454
+ async function modelsPayload(ctx) {
455
+ const cat = await ctx.sessionController.modelCatalog()
456
+ // the harness itself, as an entry: the agent loop with tools, not a bare model
457
+ const models = [{ id: AGENT_MODEL, name: 'DeepSeek Harness · Agent (tools)', provider: 'DeepSeek Harness', vision: true }]
458
+ for (const g of cat.groups ?? []) {
459
+ const vision = await visionIdsOf(ctx, g.id, g.models ?? [])
460
+ for (const m of g.models ?? []) models.push({ id: `${g.id}/${m.id}`, name: m.name ?? m.id, provider: g.name ?? g.id, vision: vision.has(m.id) })
461
+ }
462
+ const def = cat.default ? `${cat.default.provider}/${cat.default.model}` : null
463
+ return {
464
+ models,
465
+ default: def && models.some(m => m.id === def) ? def : (models[0]?.id ?? null),
466
+ capabilities: { webSearch: false, searchEngine: 'none', scholarSearch: false, vision: models.some(m => m.vision) },
467
+ harness: { routableProviders: cat.routableProviders ?? [], failures: (cat.failures ?? []).map(f => ({ provider: f.id, message: f.message })) },
468
+ }
469
+ }
470
+
471
+ const IMAGE_MEDIA = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
472
+
473
+ /** The SPA's { role, content } messages as harness Messages; system lines
474
+ * fold into the call's system prompt. Images (base64) attach to the last
475
+ * user message as image blocks. */
476
+ async function callOf(ctx, body, target) {
477
+ const system = []
478
+ const messages = []
479
+ for (const m of Array.isArray(body?.messages) ? body.messages : []) {
480
+ if (!m || typeof m.content !== 'string') continue
481
+ if (m.role === 'system') { system.push(m.content); continue }
482
+ const role = m.role === 'assistant' ? 'assistant' : 'user'
483
+ messages.push({
484
+ id: 'td-' + randomUUID(), role,
485
+ content: [{ type: 'text', text: m.content }],
486
+ source: role === 'assistant' ? { kind: 'model', provider: target.provider, model: target.model } : { kind: 'user' },
487
+ })
488
+ }
489
+ if (messages.length === 0) throw new HttpError(400, 'messages: nothing to send')
490
+ // the canvas sends images only to a model it was told sees them; they
491
+ // enter the attachment store first (the harness's image contract), then
492
+ // ride the last user message as image blocks
493
+ const images = Array.isArray(body?.images) ? body.images.filter(i => i && typeof i.data === 'string' && IMAGE_MEDIA.has(i.mimeType)) : []
494
+ if (images.length > 0) {
495
+ let lastUser = null
496
+ for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === 'user') { lastUser = messages[i]; break }
497
+ if (lastUser) {
498
+ for (const img of images) {
499
+ try {
500
+ const ref = await ctx.attachments.saveImage({ data: Buffer.from(img.data, 'base64'), mediaType: img.mimeType, name: 'canvas-image' })
501
+ lastUser.content.push({ type: 'image', attachment: ref })
502
+ } catch (error) {
503
+ throw new HttpError(400, 'image rejected by the harness: ' + (error instanceof Error ? error.message : String(error)))
504
+ }
505
+ }
506
+ }
507
+ }
508
+ return { provider: target.provider, model: target.model, messages, ...(system.length ? { system: system.join('\n\n') } : {}) }
509
+ }
510
+
511
+ /** The canvas's compiled messages, split for the agent: the last user
512
+ * message is the question; everything before it is the context the canvas
513
+ * wired in, rendered as one block the harness receives ahead of the turn. */
514
+ function compileForAgent(body) {
515
+ const msgs = (Array.isArray(body?.messages) ? body.messages : []).filter(m => m && typeof m.content === 'string' && m.content.trim())
516
+ if (msgs.length === 0) throw new HttpError(400, 'messages: nothing to send')
517
+ const last = msgs[msgs.length - 1]
518
+ if (last.role !== 'user') throw new HttpError(400, 'the last message must be the user\'s question')
519
+ const system = msgs.slice(0, -1).filter(m => m.role === 'system').map(m => m.content.trim())
520
+ const history = msgs.slice(0, -1).filter(m => m.role !== 'system')
521
+ const parts = []
522
+ if (system.length) parts.push('Instructions from the canvas:\n\n' + system.join('\n\n'))
523
+ if (history.length) parts.push('The conversation the canvas wired into this question, oldest first:\n\n' + history.map(m => `[${m.role === 'assistant' ? 'assistant' : 'user'}]\n${m.content.trim()}`).join('\n\n'))
524
+ const context = parts.length ? '[ThoughtDAG canvas context] The question that follows was asked from a ThoughtDAG canvas. Treat the material below as the conversation so far.\n\n' + parts.join('\n\n') : ''
525
+ return { question: last.content.trim(), context }
526
+ }
527
+
528
+ /** A short line naming what a tool call is doing, for the canvas's progress row. */
529
+ function toolQuery(name, args) {
530
+ let a = args
531
+ if (typeof a === 'string') { try { a = JSON.parse(a) } catch { return a.slice(0, 120) } }
532
+ if (!a || typeof a !== 'object') return ''
533
+ if (/^run_code$/i.test(name) && typeof a.code === 'string') return (a.code.split('\n').find(l => l.trim()) ?? '').trim().slice(0, 120)
534
+ for (const k of ['file_path', 'command', 'pattern', 'path', 'url', 'query', 'description']) if (typeof a[k] === 'string' && a[k]) return a[k].slice(0, 120)
535
+ const first = Object.values(a).find(v => typeof v === 'string' && v)
536
+ return first ? first.slice(0, 120) : ''
537
+ }
538
+
539
+ /** Run one question through the harness's agent loop in a fresh session and
540
+ * report it as SSE-style frames via `emit`; resolves when the turn ends. */
541
+ async function runAgentTurn(ctx, body, emit, isClosed) {
542
+ const { question, context } = compileForAgent(body)
543
+ // continue the session the canvas mirrors (a tail follow-up), else a fresh
544
+ // session in the requested working directory (the canvas's project)
545
+ const continueId = typeof body?.harness?.session === 'string' && body.harness.session ? body.harness.session : null
546
+ let sessionId
547
+ if (continueId && (liveSession(ctx, continueId) || (await eventsOfSession(ctx, continueId)) !== null)) {
548
+ sessionId = continueId
549
+ } else {
550
+ const cwd = typeof body?.harness?.cwd === 'string' && body.harness.cwd ? { cwd: body.harness.cwd } : {}
551
+ sessionId = (await ctx.sessionController.create({ ...cwd })).sessionId
552
+ }
553
+ emit({ harnessSession: sessionId, continued: sessionId === continueId })
554
+ const hasImages = Array.isArray(body?.images) && body.images.length > 0
555
+ if (hasImages) {
556
+ const vm = await visionModel(ctx).catch(() => null)
557
+ if (vm) await ctx.sessionController.selectModel({ sessionId, provider: vm.provider, model: vm.model }).catch(() => {})
558
+ }
559
+ const agent = await agentOf(ctx, sessionId)
560
+ let sawChunk = false
561
+ let fullText = ''
562
+ let done
563
+ const finished = new Promise(resolve => { done = resolve })
564
+ let currentTurn = null
565
+ const off = ctx.on('session/event', (session, event) => {
566
+ if (session?.id !== sessionId || !event) return
567
+ if (event.type === 'turn/start') { currentTurn = event.data?.turn ?? null; return }
568
+ // the person's question entering the surface: THIS is the turn the
569
+ // canvas node stands for — with its id the canvas marks the node as the
570
+ // mirror of that turn, and the live mirror does not append it a second time
571
+ if (event.type === 'user/message' && (event.data?.source?.kind ?? 'user') === 'user') {
572
+ emit({ harnessTurn: { session: sessionId, turn: currentTurn, userMessageId: event.data?.id ?? null, seq: event.seq ?? null } })
573
+ return
574
+ }
575
+ if (event.type === 'assistant/chunk') {
576
+ const c = event.data?.chunk
577
+ if (c?.type === 'text-delta' && c.text) { sawChunk = true; fullText += c.text; emit({ text: c.text }) }
578
+ else if (c?.type === 'reasoning-delta' && c.text) emit({ reasoning: c.text })
579
+ } else if (event.type === 'assistant/message' && !sawChunk) {
580
+ // chunks did not reach us live (e.g. a compacted row): the whole text at once
581
+ const text = (event.data?.message?.content ?? []).filter(b => b.type === 'text').map(b => b.text).join('')
582
+ if (text) { fullText += text; emit({ text }) }
583
+ } else if (event.type === 'tool/call') {
584
+ emit({ tool: { name: event.data?.name ?? 'tool', query: toolQuery(event.data?.name, event.data?.arguments) } })
585
+ } else if (event.type === 'tool/code-dispatch-start') {
586
+ emit({ tool: { name: event.data?.name ?? 'tool', query: toolQuery(event.data?.name, event.data?.arguments) } })
587
+ } else if (event.type === 'turn/end') {
588
+ done()
589
+ }
590
+ })
591
+ try {
592
+ const injectContext = continueId && sessionId === continueId ? (typeof body?.harness?.extraContext === 'string' ? body.harness.extraContext : '') : context
593
+ if (injectContext) agent.inject({ id: 'td-' + randomUUID(), role: 'user', content: [{ type: 'text', text: injectContext }], source: SOURCE })
594
+ const promptContent = [{ type: 'text', text: question }]
595
+ for (const img of Array.isArray(body?.images) ? body.images : []) {
596
+ if (img && typeof img.data === 'string' && IMAGE_MEDIA.has(img.mimeType)) promptContent.push({ type: 'image', mediaType: img.mimeType, data: img.data })
597
+ }
598
+ await ctx.sessionController.prompt({ requestId: 'td-' + randomUUID(), sessionId, mode: 'queue', content: promptContent }, AbortSignal.timeout(30000))
599
+ const timeout = new Promise(resolve => setTimeout(resolve, 20 * 60 * 1000))
600
+ const closed = new Promise(resolve => { const t = setInterval(() => { if (isClosed()) { clearInterval(t); resolve() } }, 500); finished.then(() => clearInterval(t)) })
601
+ await Promise.race([finished, agent.whenIdle().then(() => finished), timeout, closed])
602
+ if (isClosed()) { try { agent.cancel({ kind: 'user' }) } catch { /* best effort */ } }
603
+ } finally {
604
+ if (typeof off === 'function') off()
605
+ }
606
+ return { sessionId, text: fullText }
607
+ }
608
+
609
+ /** A vision-capable model id ("provider/model") from the catalog, or null. */
610
+ async function visionModel(ctx) {
611
+ const cat = await ctx.sessionController.modelCatalog()
612
+ for (const g of cat.groups ?? []) for (const m of g.models ?? []) if (/vision/i.test(m.id) || /vision/i.test(m.name ?? '')) return { provider: g.id, model: m.id }
613
+ return null
614
+ }
615
+
616
+ /** Resolve the requested model against the catalog, falling back to its default. */
617
+ async function targetOf(ctx, requested) {
618
+ const payload = await modelsPayload(ctx)
619
+ const id = requested && payload.models.some(m => m.id === requested) ? requested : payload.default
620
+ const t = splitModelId(id)
621
+ if (!t) throw new HttpError(503, 'no model is routable in this harness')
622
+ return t
623
+ }
624
+
625
+ /** The live agent for a session, resuming a cold one through the controller. */
626
+ async function agentOf(ctx, id) {
627
+ const live = ctx.agents.get(id)
628
+ if (live !== undefined) return live
629
+ const r = await ctx.sessionController.resolveAgent(id)
630
+ if ('agent' in r) return r.agent
631
+ throw new HttpError(404, 'no such session: ' + (r.error?.code ?? r.error?.message ?? 'unknown'))
632
+ }
633
+
634
+ export function apply(ctx, config) {
635
+ const prefix = typeof config?.mountPrefix === 'string' && config.mountPrefix.startsWith('/') && config.mountPrefix.length > 1
636
+ ? config.mountPrefix.replace(/\/+$/, '')
637
+ : '/thoughtdag'
638
+ const trustedHosts = new Set(['localhost', '127.0.0.1', ...[...(config?.trustedHosts ?? [])].map(h => String(h).trim().toLowerCase()).filter(Boolean)])
639
+
640
+ const api = async (req, res) => {
641
+ try {
642
+ const hostname = (typeof req.headers.host === 'string' ? req.headers.host : '').replace(/:\d+$/, '').toLowerCase()
643
+ if (!trustedHosts.has(hostname)) return sendJson(res, 403, { error: 'untrusted Host header' })
644
+ const url = new URL(req.url ?? '/', 'http://dsh.local')
645
+ // full pathname is /<prefix>/api/<rest>; the register prefix strips /api
646
+ const path = url.pathname.slice((prefix + '/api').length)
647
+ // Durable sessions on disk — the archive ThoughtDAG's canvas imports.
648
+ if (path === '/disksessions' && req.method === 'GET') {
649
+ const dshHome = process.env.DSH_HOME || join(os.homedir(), '.dsh')
650
+ const files = await findSessionFiles(dshHome)
651
+ const sessions = []
652
+ for (const f of files) {
653
+ const head = await sessionTitleFromHead(f.log)
654
+ sessions.push({
655
+ id: head.id ?? f.sessionDir,
656
+ title: head.title,
657
+ cwd: head.cwd,
658
+ workspaceDir: f.dir,
659
+ size: f.size,
660
+ mtime: f.mtime,
661
+ })
662
+ }
663
+ sessions.sort((a, b) => b.mtime - a.mtime)
664
+ return sendJson(res, 200, { sessions })
665
+ }
666
+ const diskOne = /^\/disksessions\/([^/]+?)(\/log)?$/.exec(path)
667
+ if (diskOne !== null && req.method === 'GET') {
668
+ const name = decodeURIComponent(diskOne[1])
669
+ const dshHome = process.env.DSH_HOME || join(os.homedir(), '.dsh')
670
+ const files = await findSessionFiles(dshHome)
671
+ const hit = files.find(f => f.sessionDir === name || f.sessionDir.startsWith(name))
672
+ if (!hit) return sendJson(res, 404, { error: 'no such session on disk' })
673
+ const raw = await readFile(hit.log).catch(() => null)
674
+ if (!raw) return sendJson(res, 500, { error: 'session unreadable' })
675
+ if (diskOne[2] === '/log') return sendFile(res, 'application/x-ndjson; charset=utf-8', decompressZstdToText(raw))
676
+ const head = await sessionTitleFromHead(hit.log)
677
+ return sendJson(res, 200, { session: { id: head.id ?? hit.sessionDir, title: head.title, cwd: head.cwd, size: hit.size, mtime: hit.mtime } })
678
+ }
679
+ // Live sessions (this process's in-memory store).
680
+ if (path === '/sessions' && req.method === 'GET') {
681
+ const list = ctx.sessions.list()
682
+ return sendJson(res, 200, { sessions: list.map(sessionSummary) })
683
+ }
684
+ const one = /^\/sessions\/([^/]+?)(\/log)?$/.exec(path)
685
+ if (one !== null && req.method === 'GET') {
686
+ const id = decodeURIComponent(one[1])
687
+ const session = liveSession(ctx, id)
688
+ if (session === undefined) return sendJson(res, 404, { error: 'no such session' })
689
+ if (one[2] === '/log') return sendFile(res, 'application/x-ndjson; charset=utf-8', sessionToJsonl(session))
690
+ return sendJson(res, 200, { session: sessionSummary(session) })
691
+ }
692
+ // ── the other agents' session files ──
693
+ if (path === '/roots' && req.method === 'GET') {
694
+ const roots = []
695
+ for (const [key, p] of Object.entries(FILE_ROOTS)) roots.push({ key, path: p, builtin: true, exists: await stat(p).then(st => st.isDirectory()).catch(() => false) })
696
+ return sendJson(res, 200, { roots })
697
+ }
698
+ const rootRoute = /^\/roots\/([a-z-]+)\/(list|head|read|range)$/.exec(path)
699
+ if (rootRoute !== null && req.method === 'GET') {
700
+ const key = rootRoute[1]
701
+ if (rootRoute[2] === 'list') return sendJson(res, 200, { files: await listRoot(key) })
702
+ const rel = url.searchParams.get('rel') ?? ''
703
+ const abs = fileInRoot(key, rel)
704
+ if (rootRoute[2] === 'head') return sendFile(res, 'text/plain; charset=utf-8', await headOfFile(abs, Number(url.searchParams.get('bytes')) || 16384).catch(() => ''))
705
+ if (rootRoute[2] === 'read') {
706
+ const st = await stat(abs).catch(() => null)
707
+ if (!st) return sendJson(res, 404, { error: 'no such file' })
708
+ if (st.size > FILE_READ_MAX) return sendJson(res, 413, { error: 'file too large to read whole; use range' })
709
+ return sendFile(res, 'application/x-ndjson; charset=utf-8', await readFile(abs))
710
+ }
711
+ return sendJson(res, 200, await rangeOfFile(abs, url.searchParams.get('start'), url.searchParams.get('length')))
712
+ }
713
+ // ── link snapshots: the SPA's /api/fetch-url on the harness's bounded fetcher ──
714
+ if (path === '/fetch-url' && req.method === 'POST') {
715
+ const body = await readJson(req)
716
+ const target = typeof body?.url === 'string' ? body.url.trim() : ''
717
+ if (!/^https?:\/\//i.test(target)) return sendJson(res, 400, { error: 'url must be http(s)' })
718
+ let r
719
+ try { r = await ctx.web.fetch({ url: target }, AbortSignal.timeout(30000)) } catch (error) { return sendJson(res, 502, { error: error instanceof Error ? error.message : String(error) }) }
720
+ if (r.statusCode >= 400) return sendJson(res, 502, { error: `HTTP ${r.statusCode} from ${target}` })
721
+ const html = r.body?.kind === 'html' ? r.body.content : null
722
+ const title = (html && /<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1]?.trim()) || new URL(target).hostname
723
+ const text = html
724
+ ? html.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/\s+\n/g, '\n').replace(/[ \t]+/g, ' ').trim()
725
+ : String(r.body?.content ?? '')
726
+ return sendJson(res, 200, { title, text, fetchedAt: new Date().toISOString(), ...(html ? { html } : {}), url: r.url ?? target, truncated: !!r.truncated })
727
+ }
728
+ // ── model connection (the SPA's proxy protocol, on the harness's providers) ──
729
+ if (path === '/models' && req.method === 'GET') return sendJson(res, 200, await modelsPayload(ctx))
730
+ if ((path === '/stream' || path === '/claude') && req.method === 'POST') {
731
+ const body = await readJson(req, MAX_CALL_BODY_BYTES)
732
+ if (body?.model === AGENT_MODEL) {
733
+ if (path === '/claude') {
734
+ const r = await runAgentTurn(ctx, body, () => {}, () => false)
735
+ return sendJson(res, 200, { text: r.text, model: AGENT_MODEL, harnessSession: r.sessionId })
736
+ }
737
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
738
+ let closed = false
739
+ req.on('close', () => { closed = true })
740
+ try {
741
+ await runAgentTurn(ctx, body, frame => { if (!closed) res.write(`data: ${JSON.stringify(frame)}\n\n`) }, () => closed)
742
+ if (!closed) res.write('data: [DONE]\n\n')
743
+ } catch (error) {
744
+ if (!closed) res.write(`data: ${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n\n`)
745
+ }
746
+ res.end()
747
+ return
748
+ }
749
+ const target = await targetOf(ctx, body?.model)
750
+ const options = await callOf(ctx, body, target)
751
+ if (path === '/claude') {
752
+ let text = ''
753
+ for await (const chunk of ctx.llm.stream(options)) if (chunk.type === 'text-delta') text += chunk.text
754
+ return sendJson(res, 200, { text, model: `${target.provider}/${target.model}` })
755
+ }
756
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
757
+ let closed = false
758
+ req.on('close', () => { closed = true })
759
+ try {
760
+ for await (const chunk of ctx.llm.stream(options)) {
761
+ if (closed) break
762
+ if (chunk.type === 'text-delta' && chunk.text) res.write(`data: ${JSON.stringify({ text: chunk.text })}\n\n`)
763
+ else if (chunk.type === 'reasoning-delta' && chunk.text) res.write(`data: ${JSON.stringify({ reasoning: chunk.text })}\n\n`)
764
+ }
765
+ if (!closed) res.write('data: [DONE]\n\n')
766
+ } catch (error) {
767
+ if (!closed) res.write(`data: ${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n\n`)
768
+ }
769
+ res.end()
770
+ return
771
+ }
772
+ // ── write direction ──
773
+ const turnsRoute = /^\/sessions\/([^/]+)\/turns$/.exec(path)
774
+ if (turnsRoute !== null && req.method === 'GET') {
775
+ const id = decodeURIComponent(turnsRoute[1])
776
+ const events = await eventsOfSession(ctx, id)
777
+ if (events === null) return sendJson(res, 404, { error: 'no such session' })
778
+ return sendJson(res, 200, { session: id, turns: turnsOf(events) })
779
+ }
780
+ const write = /^\/sessions\/([^/]+)\/(fork|inject|followup)$/.exec(path)
781
+ if (write !== null && req.method === 'POST') {
782
+ const id = decodeURIComponent(write[1])
783
+ const body = await readJson(req, MAX_WRITE_BODY_BYTES)
784
+ if (write[2] === 'fork') {
785
+ // the boundary: an explicit seq, or the end of a named turn
786
+ let atSeq = typeof body?.atSeq === 'number' ? body.atSeq : undefined
787
+ if (typeof body?.afterTurn === 'number') {
788
+ const events = await eventsOfSession(ctx, id)
789
+ if (events === null) return sendJson(res, 404, { error: 'no such session' })
790
+ const t = turnsOf(events).find(x => x.turn === body.afterTurn)
791
+ if (!t) return sendJson(res, 400, { error: 'no such turn: ' + body.afterTurn })
792
+ if (t.endSeq === null) return sendJson(res, 409, { error: 'turn ' + body.afterTurn + ' is still open; a fork needs a completed turn' })
793
+ atSeq = t.endSeq
794
+ }
795
+ try {
796
+ const r = await ctx.sessionController.fork({ sessionId: id, ...(atSeq !== undefined ? { atSeq } : {}) })
797
+ return sendJson(res, 200, { session: r.sessionId, parent: id, atSeq: atSeq ?? null })
798
+ } catch (error) {
799
+ const code = error?.code ?? error?.name ?? 'fork failed'
800
+ return sendJson(res, /NOT_FOUND/.test(String(code)) ? 404 : 409, { error: String(error?.message ?? code), code })
801
+ }
802
+ }
803
+ const content = contentOf(body)
804
+ if (write[2] === 'inject') {
805
+ const agent = await agentOf(ctx, id)
806
+ const message = { id: 'td-' + randomUUID(), role: 'user', content, source: SOURCE }
807
+ agent.inject(message)
808
+ return sendJson(res, 200, { accepted: true, session: id, messageId: message.id })
809
+ }
810
+ // followup: a person's prompt from the canvas — the controller owns
811
+ // the user source and the queue/steer semantics the UI uses
812
+ const requestId = 'td-' + randomUUID()
813
+ const mode = body?.mode === 'steer' ? 'steer' : 'queue'
814
+ await ctx.sessionController.prompt({ requestId, sessionId: id, mode, content }, AbortSignal.timeout(30000))
815
+ return sendJson(res, 200, { accepted: true, session: id, requestId, mode })
816
+ }
817
+ return sendJson(res, 404, { error: 'not found' })
818
+ } catch (error) {
819
+ if (error instanceof HttpError) return sendJson(res, error.status, { error: error.message })
820
+ ctx.logger.error(error instanceof Error ? error : new Error(String(error)))
821
+ // the bridge only answers trusted local hosts, so the message may travel
822
+ return sendJson(res, 500, { error: 'bridge unavailable', detail: error instanceof Error ? error.message : String(error) })
823
+ }
824
+ }
825
+
826
+ const staticHandler = async (req, res) => {
827
+ const url = new URL(req.url ?? '/', 'http://dsh.local')
828
+ let rel = url.pathname.slice(prefix.length).replace(/^\/+/, '') || 'index.html'
829
+ if (rel.endsWith('/')) rel += 'index.html'
830
+ if (url.pathname.startsWith(prefix + '/api/')) { rel = 'index.html' } // api never reaches the static table
831
+ const abs = assetPath(rel)
832
+ if (abs === null) { res.writeHead(403); res.end('forbidden'); return }
833
+ try {
834
+ const body = await readFile(abs)
835
+ sendFile(res, MIME[extname(abs).toLowerCase()] ?? 'application/octet-stream', body)
836
+ } catch {
837
+ res.writeHead(404); res.end('not found')
838
+ }
839
+ }
840
+
841
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: prefix, handler: (_req, res) => { res.writeHead(302, { location: prefix + '/' }); res.end() } }), 'thoughtdag: redirect')
842
+ ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: prefix + '/api', handler: api }), 'thoughtdag: api')
843
+ ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: prefix, handler: staticHandler }), 'thoughtdag: static')
844
+ ctx.logger.info('[dsh-thoughtdag] ThoughtDAG mounted at ' + prefix + '/')
845
+ }