agentquad 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +318 -0
  3. package/dist-web/assets/index-CMaXwixo.js +1234 -0
  4. package/dist-web/assets/index-DBHApzV1.css +32 -0
  5. package/dist-web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
  6. package/dist-web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
  7. package/dist-web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
  8. package/dist-web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
  9. package/dist-web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
  10. package/dist-web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
  11. package/dist-web/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
  12. package/dist-web/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
  13. package/dist-web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
  14. package/dist-web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
  15. package/dist-web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
  16. package/dist-web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
  17. package/dist-web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
  18. package/dist-web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
  19. package/dist-web/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
  20. package/dist-web/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
  21. package/dist-web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
  22. package/dist-web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
  23. package/dist-web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
  24. package/dist-web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
  25. package/dist-web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
  26. package/dist-web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
  27. package/dist-web/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
  28. package/dist-web/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
  29. package/dist-web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
  30. package/dist-web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
  31. package/dist-web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
  32. package/dist-web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
  33. package/dist-web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
  34. package/dist-web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
  35. package/dist-web/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
  36. package/dist-web/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
  37. package/dist-web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  38. package/dist-web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  39. package/dist-web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  40. package/dist-web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  41. package/dist-web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  42. package/dist-web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  43. package/dist-web/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
  44. package/dist-web/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  45. package/dist-web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
  46. package/dist-web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
  47. package/dist-web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
  48. package/dist-web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
  49. package/dist-web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
  50. package/dist-web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
  51. package/dist-web/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
  52. package/dist-web/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
  53. package/dist-web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
  54. package/dist-web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
  55. package/dist-web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
  56. package/dist-web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
  57. package/dist-web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
  58. package/dist-web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
  59. package/dist-web/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
  60. package/dist-web/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
  61. package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  62. package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  63. package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-BWTpRfYl.woff2 +0 -0
  64. package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-CEoEElIJ.woff +0 -0
  65. package/dist-web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  66. package/dist-web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  67. package/dist-web/assets/jetbrains-mono-greek-700-normal-C6CZE3T8.woff2 +0 -0
  68. package/dist-web/assets/jetbrains-mono-greek-700-normal-DEigVDxa.woff +0 -0
  69. package/dist-web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  70. package/dist-web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  71. package/dist-web/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
  72. package/dist-web/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
  73. package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  74. package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  75. package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CZipNAKV.woff2 +0 -0
  76. package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CxPITLHs.woff +0 -0
  77. package/dist-web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  78. package/dist-web/assets/jetbrains-mono-vietnamese-700-normal-BDLVIk2r.woff +0 -0
  79. package/dist-web/assets/logo-D4DDtU-r.png +0 -0
  80. package/dist-web/favicon.png +0 -0
  81. package/dist-web/index.html +14 -0
  82. package/package.json +88 -0
  83. package/src/ask-user-buttons.js +142 -0
  84. package/src/claude-transcript.js +203 -0
  85. package/src/cli.js +1040 -0
  86. package/src/codex-event-emitter.js +111 -0
  87. package/src/codex-prompt-detector.js +53 -0
  88. package/src/codex-sidecar.js +52 -0
  89. package/src/codex-transcript.js +74 -0
  90. package/src/config.js +692 -0
  91. package/src/data/claude-code-commands.json +52 -0
  92. package/src/db.js +1503 -0
  93. package/src/dispatch.js +13 -0
  94. package/src/export/todoMarkdown.js +246 -0
  95. package/src/first-run-wizard.js +82 -0
  96. package/src/git/gitStatus.js +139 -0
  97. package/src/lark-api-client.js +205 -0
  98. package/src/lark-bot.js +510 -0
  99. package/src/lark-card.js +88 -0
  100. package/src/lark-config-service.js +16 -0
  101. package/src/lark-event-client.js +107 -0
  102. package/src/lark-image.js +99 -0
  103. package/src/lark-markdown.js +51 -0
  104. package/src/lark-video.js +163 -0
  105. package/src/mcp/audit.js +34 -0
  106. package/src/mcp/server.js +83 -0
  107. package/src/mcp/tools/destructive/index.js +252 -0
  108. package/src/mcp/tools/openclaw/index.js +405 -0
  109. package/src/mcp/tools/read/index.js +269 -0
  110. package/src/mcp/tools/write/index.js +157 -0
  111. package/src/openclaw-bridge.js +566 -0
  112. package/src/openclaw-hook-installer.js +338 -0
  113. package/src/openclaw-hook.js +908 -0
  114. package/src/openclaw-wizard.js +2442 -0
  115. package/src/pending-questions.js +297 -0
  116. package/src/pricing.js +45 -0
  117. package/src/prompt-render.js +36 -0
  118. package/src/pty.js +992 -0
  119. package/src/routes/ai-terminal.js +1228 -0
  120. package/src/routes/git.js +89 -0
  121. package/src/routes/openclaw-hook.js +67 -0
  122. package/src/routes/openclaw-inbound.js +36 -0
  123. package/src/routes/recurringRules.js +80 -0
  124. package/src/routes/reports.js +50 -0
  125. package/src/routes/search.js +46 -0
  126. package/src/routes/stats.js +31 -0
  127. package/src/routes/telegram-config.js +152 -0
  128. package/src/routes/telegram-sync.js +221 -0
  129. package/src/routes/templates.js +63 -0
  130. package/src/routes/todos.js +649 -0
  131. package/src/routes/transcripts.js +75 -0
  132. package/src/routes/uploads.js +107 -0
  133. package/src/routes/wiki.js +142 -0
  134. package/src/search/fts.js +209 -0
  135. package/src/search/index.js +199 -0
  136. package/src/search/transcripts.js +148 -0
  137. package/src/server.js +1791 -0
  138. package/src/session-input-dispatcher.js +256 -0
  139. package/src/stats/markdown.js +42 -0
  140. package/src/stats/report.js +207 -0
  141. package/src/summarize.js +84 -0
  142. package/src/system-rules.js +52 -0
  143. package/src/telegram-bot.js +875 -0
  144. package/src/telegram-commands.js +149 -0
  145. package/src/telegram-config-service.js +84 -0
  146. package/src/telegram-image.js +95 -0
  147. package/src/telegram-loading-status.js +112 -0
  148. package/src/telegram-markdown.js +82 -0
  149. package/src/telegram-reaction-tracker.js +69 -0
  150. package/src/telegram-video.js +75 -0
  151. package/src/templates/claude-hooks/notify.js +103 -0
  152. package/src/transcript.js +305 -0
  153. package/src/transcripts/blocks.js +56 -0
  154. package/src/transcripts/index.js +222 -0
  155. package/src/transcripts/indexer.js +34 -0
  156. package/src/transcripts/matcher.js +70 -0
  157. package/src/transcripts/scanner.js +259 -0
  158. package/src/usage-footer.js +170 -0
  159. package/src/usage-parser.js +132 -0
  160. package/src/wiki/guide.js +44 -0
  161. package/src/wiki/index.js +232 -0
  162. package/src/wiki/redact.js +34 -0
  163. package/src/wiki/sources.js +122 -0
@@ -0,0 +1,649 @@
1
+ import { Router } from 'express'
2
+ import { watch as fsWatch } from 'node:fs'
3
+ import { loadTranscript, renderPtyLogText } from '../transcript.js'
4
+ import { summarizeTurns } from '../summarize.js'
5
+ import { buildTodoExport, renderTodoMarkdown } from '../export/todoMarkdown.js'
6
+ import { SUPPORTED_TOOLS } from '../config.js'
7
+
8
+ const ALLOWED_STAGE_TAGS = ['dev', 'review', 'test', 'release', 'blocked']
9
+
10
+ export function createTodosRouter({ db, logDir, getPricing, getTools, getLiveSession, getPty }) {
11
+ const router = Router()
12
+
13
+ router.get('/', (req, res) => {
14
+ try {
15
+ try { db.sweepRecurring(Date.now()) } catch (e) { console.warn('[sweepRecurring]', e?.message) }
16
+ const { quadrant, status, keyword } = req.query
17
+ const list = db.listTodos({ quadrant, status, keyword })
18
+ res.json({ ok: true, list })
19
+ } catch (e) {
20
+ res.status(500).json({ ok: false, error: e.message })
21
+ }
22
+ })
23
+
24
+ router.post('/', (req, res) => {
25
+ try {
26
+ const { title, description, quadrant, dueDate, workDir, brainstorm, appliedTemplateIds, parentId } = req.body || {}
27
+ if (!title || typeof title !== 'string') {
28
+ res.status(400).json({ ok: false, error: 'missing title' })
29
+ return
30
+ }
31
+ const parent = parentId ? db.getTodo(parentId) : null
32
+ if (parentId && !parent) {
33
+ res.status(400).json({ ok: false, error: 'parent_not_found' })
34
+ return
35
+ }
36
+ if (parent?.parentId) {
37
+ res.status(400).json({ ok: false, error: 'nested_subtodo_not_allowed' })
38
+ return
39
+ }
40
+ const q = parent ? parent.quadrant : (Number(quadrant) || 4)
41
+ if (![1, 2, 3, 4].includes(q)) {
42
+ res.status(400).json({ ok: false, error: 'invalid quadrant' })
43
+ return
44
+ }
45
+ const todo = db.createTodo({
46
+ title,
47
+ description: description || '',
48
+ quadrant: q,
49
+ dueDate: dueDate ?? null,
50
+ workDir: workDir || null,
51
+ brainstorm: !!brainstorm,
52
+ appliedTemplateIds: Array.isArray(appliedTemplateIds) ? appliedTemplateIds : [],
53
+ parentId: parent?.id ?? null,
54
+ })
55
+ res.json({ ok: true, todo })
56
+ } catch (e) {
57
+ if (['parent_not_found', 'nested_subtodo_not_allowed', 'parent_quadrant_mismatch'].includes(e.message)) {
58
+ res.status(400).json({ ok: false, error: e.message })
59
+ return
60
+ }
61
+ res.status(500).json({ ok: false, error: e.message })
62
+ }
63
+ })
64
+
65
+ router.put('/:id', (req, res) => {
66
+ try {
67
+ const existing = db.getTodo(req.params.id)
68
+ if (!existing) {
69
+ res.status(404).json({ ok: false, error: 'not_found' })
70
+ return
71
+ }
72
+ const patch = req.body || {}
73
+ if (patch.stageTag !== undefined) {
74
+ if (patch.stageTag !== null && !ALLOWED_STAGE_TAGS.includes(patch.stageTag)) {
75
+ res.status(400).json({ ok: false, error: 'invalid_stage_tag' })
76
+ return
77
+ }
78
+ }
79
+ if (patch.parentId !== undefined) {
80
+ if (existing.parentId && patch.parentId !== existing.parentId) {
81
+ res.status(400).json({ ok: false, error: 'reparent_not_allowed' })
82
+ return
83
+ }
84
+ if (existing.parentId && patch.parentId === null) {
85
+ res.status(400).json({ ok: false, error: 'promote_not_allowed' })
86
+ return
87
+ }
88
+ if (!existing.parentId && patch.parentId !== null && patch.parentId !== existing.parentId) {
89
+ res.status(400).json({ ok: false, error: 'reparent_not_allowed' })
90
+ return
91
+ }
92
+ }
93
+ const targetParentId = patch.parentId !== undefined ? patch.parentId : existing.parentId
94
+ const parent = targetParentId ? db.getTodo(targetParentId) : null
95
+ if (targetParentId && !parent) {
96
+ res.status(400).json({ ok: false, error: 'parent_not_found' })
97
+ return
98
+ }
99
+ if (parent?.parentId) {
100
+ res.status(400).json({ ok: false, error: 'nested_subtodo_not_allowed' })
101
+ return
102
+ }
103
+ if (parent && patch.quadrant !== undefined && Number(patch.quadrant) !== parent.quadrant) {
104
+ res.status(400).json({ ok: false, error: 'parent_quadrant_mismatch' })
105
+ return
106
+ }
107
+ const todo = db.updateTodo(req.params.id, patch)
108
+
109
+ // Auto-close PTY when status transitions to 'done':
110
+ // - kill all live AI sessions of this todo and its subtodos
111
+ // - tag parent's live sessions with userClosedReason so PTY 'done' handler
112
+ // doesn't overwrite the just-written status back to 'todo'.
113
+ // - subtodo sessions are NOT tagged (subtodo lifecycle is independent;
114
+ // normal PTY 'done' handler will reset them to 'todo' which is correct)
115
+ if (existing.status !== 'done' && patch.status === 'done') {
116
+ const pty = typeof getPty === 'function' ? getPty() : null
117
+ if (pty && typeof getLiveSession === 'function') {
118
+ const parentSessions = Array.isArray(todo.aiSessions) ? todo.aiSessions : []
119
+ for (const s of parentSessions) {
120
+ const live = getLiveSession(s.sessionId)
121
+ if (live && (live.status === 'running' || live.status === 'pending_confirm' || live.status === 'idle')) {
122
+ live.userClosedReason = 'todo_marked_done'
123
+ try { pty.stop(s.sessionId) } catch (e) { console.warn('[todos] pty.stop parent failed:', e?.message) }
124
+ }
125
+ }
126
+ const subtodos = typeof db.listSubtodosByParent === 'function' ? db.listSubtodosByParent(todo.id) : []
127
+ for (const sub of subtodos) {
128
+ const subSessions = Array.isArray(sub.aiSessions) ? sub.aiSessions : []
129
+ for (const s of subSessions) {
130
+ const live = getLiveSession(s.sessionId)
131
+ if (live && (live.status === 'running' || live.status === 'pending_confirm' || live.status === 'idle')) {
132
+ try { pty.stop(s.sessionId) } catch (e) { console.warn('[todos] pty.stop subtodo failed:', e?.message) }
133
+ }
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ res.json({ ok: true, todo })
140
+ } catch (e) {
141
+ if (['parent_not_found', 'nested_subtodo_not_allowed', 'parent_quadrant_mismatch', 'parent_cycle'].includes(e.message)) {
142
+ res.status(400).json({ ok: false, error: e.message })
143
+ return
144
+ }
145
+ res.status(500).json({ ok: false, error: e.message })
146
+ }
147
+ })
148
+
149
+ router.delete('/:id', (req, res) => {
150
+ try {
151
+ const existing = db.getTodo(req.params.id)
152
+ if (!existing) {
153
+ res.status(404).json({ ok: false, error: 'not_found' })
154
+ return
155
+ }
156
+ db.deleteTodo(req.params.id)
157
+ res.json({ ok: true })
158
+ } catch (e) {
159
+ res.status(500).json({ ok: false, error: e.message })
160
+ }
161
+ })
162
+
163
+ router.get('/:id/comments', (req, res) => {
164
+ try {
165
+ const existing = db.getTodo(req.params.id)
166
+ if (!existing) {
167
+ res.status(404).json({ ok: false, error: 'not_found' })
168
+ return
169
+ }
170
+ const list = db.listComments(req.params.id)
171
+ res.json({ ok: true, list })
172
+ } catch (e) {
173
+ res.status(500).json({ ok: false, error: e.message })
174
+ }
175
+ })
176
+
177
+ router.post('/:id/comments', (req, res) => {
178
+ try {
179
+ const existing = db.getTodo(req.params.id)
180
+ if (!existing) {
181
+ res.status(404).json({ ok: false, error: 'not_found' })
182
+ return
183
+ }
184
+ const { content } = req.body || {}
185
+ if (!content || typeof content !== 'string' || !content.trim()) {
186
+ res.status(400).json({ ok: false, error: 'missing content' })
187
+ return
188
+ }
189
+ const comment = db.addComment(req.params.id, content.trim())
190
+ res.json({ ok: true, comment })
191
+ } catch (e) {
192
+ res.status(500).json({ ok: false, error: e.message })
193
+ }
194
+ })
195
+
196
+ router.delete('/:id/comments/:commentId', (req, res) => {
197
+ try {
198
+ const comment = db.getComment(req.params.commentId)
199
+ if (!comment || comment.todoId !== req.params.id) {
200
+ res.status(404).json({ ok: false, error: 'comment_not_found' })
201
+ return
202
+ }
203
+ db.deleteComment(req.params.commentId)
204
+ res.json({ ok: true })
205
+ } catch (e) {
206
+ res.status(500).json({ ok: false, error: e.message })
207
+ }
208
+ })
209
+
210
+ router.patch('/:id/ai-sessions/:sessionId', (req, res) => {
211
+ try {
212
+ const existing = db.getTodo(req.params.id)
213
+ if (!existing) {
214
+ res.status(404).json({ ok: false, error: 'not_found' })
215
+ return
216
+ }
217
+ const { label } = req.body || {}
218
+ const nextAiSessions = (existing.aiSessions || []).map(item => {
219
+ if (item?.sessionId !== req.params.sessionId) return item
220
+ return { ...item, label: typeof label === 'string' ? label.trim() : (item.label || '') }
221
+ })
222
+ const found = nextAiSessions.some(item => item?.sessionId === req.params.sessionId)
223
+ if (!found) {
224
+ res.status(404).json({ ok: false, error: 'session_not_found' })
225
+ return
226
+ }
227
+ const todo = db.updateTodo(req.params.id, { aiSessions: nextAiSessions })
228
+ res.json({ ok: true, todo })
229
+ } catch (e) {
230
+ res.status(500).json({ ok: false, error: e.message })
231
+ }
232
+ })
233
+
234
+ router.delete('/:id/ai-sessions/:sessionId', (req, res) => {
235
+ try {
236
+ const existing = db.getTodo(req.params.id)
237
+ if (!existing) {
238
+ res.status(404).json({ ok: false, error: 'not_found' })
239
+ return
240
+ }
241
+
242
+ const nextAiSessions = (existing.aiSessions || []).filter(item => item?.sessionId !== req.params.sessionId)
243
+ if (nextAiSessions.length === (existing.aiSessions || []).length) {
244
+ res.status(404).json({ ok: false, error: 'session_not_found' })
245
+ return
246
+ }
247
+
248
+ const todo = db.updateTodo(req.params.id, { aiSessions: nextAiSessions })
249
+ res.json({ ok: true, todo })
250
+ } catch (e) {
251
+ res.status(500).json({ ok: false, error: e.message })
252
+ }
253
+ })
254
+
255
+ router.get('/:id/ai-sessions/:sessionId/transcript', async (req, res) => {
256
+ try {
257
+ const todo = db.getTodo(req.params.id)
258
+ if (!todo) {
259
+ res.status(404).json({ ok: false, error: 'not_found' })
260
+ return
261
+ }
262
+ const session = (todo.aiSessions || []).find(s => s?.sessionId === req.params.sessionId)
263
+ if (!session) {
264
+ res.status(404).json({ ok: false, error: 'session_not_found' })
265
+ return
266
+ }
267
+ const liveSession = typeof getLiveSession === 'function'
268
+ ? getLiveSession(session.sessionId)
269
+ : null
270
+ const result = await loadTranscript({
271
+ tool: session.tool,
272
+ nativeSessionId: session.nativeSessionId,
273
+ cwd: session.cwd || todo.workDir || null,
274
+ sessionId: session.sessionId,
275
+ logDir,
276
+ liveOutputHistory: liveSession?.outputHistory || null,
277
+ liveTimestamp: liveSession?.lastOutputAt || Date.now(),
278
+ })
279
+ const since = Number(req.query.since)
280
+ const turns = Number.isFinite(since) && since > 0
281
+ ? result.turns.slice(since)
282
+ : result.turns
283
+ res.json({
284
+ ok: true,
285
+ source: result.source,
286
+ total: result.turns.length,
287
+ offset: Number.isFinite(since) && since > 0 ? since : 0,
288
+ turns,
289
+ session: {
290
+ sessionId: session.sessionId,
291
+ tool: session.tool,
292
+ nativeSessionId: session.nativeSessionId,
293
+ status: session.status,
294
+ label: session.label || '',
295
+ startedAt: session.startedAt,
296
+ completedAt: session.completedAt,
297
+ },
298
+ })
299
+ } catch (e) {
300
+ console.error('[transcript]', e)
301
+ res.status(500).json({ ok: false, error: e.message })
302
+ }
303
+ })
304
+
305
+ // SSE 推流:jsonl 源 fs.watch 实时推送新 turn;ptylog 源推一次 snapshot 后让客户端 fallback 轮询
306
+ router.get('/:id/ai-sessions/:sessionId/transcript/stream', async (req, res) => {
307
+ const todo = db.getTodo(req.params.id)
308
+ if (!todo) { res.status(404).json({ ok: false, error: 'not_found' }); return }
309
+ const session = (todo.aiSessions || []).find(s => s?.sessionId === req.params.sessionId)
310
+ if (!session) { res.status(404).json({ ok: false, error: 'session_not_found' }); return }
311
+
312
+ res.set({
313
+ 'Content-Type': 'text/event-stream; charset=utf-8',
314
+ 'Cache-Control': 'no-cache, no-transform',
315
+ 'Connection': 'keep-alive',
316
+ 'X-Accel-Buffering': 'no',
317
+ })
318
+ res.flushHeaders?.()
319
+
320
+ let closed = false
321
+ const sendEvent = (event, data) => {
322
+ if (closed) return
323
+ try {
324
+ res.write(`event: ${event}\n`)
325
+ res.write(`data: ${JSON.stringify(data)}\n\n`)
326
+ } catch { /* client gone */ }
327
+ }
328
+
329
+ const loadArgs = () => ({
330
+ tool: session.tool,
331
+ nativeSessionId: session.nativeSessionId,
332
+ cwd: session.cwd || todo.workDir || null,
333
+ sessionId: session.sessionId,
334
+ logDir,
335
+ liveOutputHistory: (typeof getLiveSession === 'function' ? getLiveSession(session.sessionId) : null)?.outputHistory || null,
336
+ liveTimestamp: (typeof getLiveSession === 'function' ? getLiveSession(session.sessionId) : null)?.lastOutputAt || Date.now(),
337
+ })
338
+
339
+ let initial
340
+ try { initial = await loadTranscript(loadArgs()) }
341
+ catch (e) { sendEvent('error', { message: e.message }); res.end(); return }
342
+
343
+ sendEvent('snapshot', {
344
+ source: initial.source,
345
+ turns: initial.turns,
346
+ total: initial.turns.length,
347
+ session: {
348
+ sessionId: session.sessionId,
349
+ tool: session.tool,
350
+ nativeSessionId: session.nativeSessionId,
351
+ status: session.status,
352
+ label: session.label || '',
353
+ startedAt: session.startedAt,
354
+ completedAt: session.completedAt,
355
+ },
356
+ })
357
+
358
+ let watcher = null
359
+ let debounceTimer = null
360
+ let keepaliveTimer = null
361
+ let lastTotal = initial.turns.length
362
+ // Live PTY streaming:Claude Code 的 jsonl 只在每条消息"收尾"时才落盘,
363
+ // token 级别的增量看不到;订阅 PTY 输出并渲染成文本,让 Chat 续聊 tab
364
+ // 也能实时看到正在生成的内容。
365
+ let ptyListener = null
366
+ let liveDebounceTimer = null
367
+ let lastLiveContent = ''
368
+ // resume 刚起来那会儿,新 session 的 jsonl 还没写出来。把 findClaudeFile
369
+ // 放进定时轮询里,等 Claude 写出新 jsonl 再补挂 fsWatch + 对齐 lastTotal。
370
+ let jsonlRecheckTimer = null
371
+ const pty = typeof getPty === 'function' ? getPty() : null
372
+ const hasPty = pty && typeof pty.on === 'function'
373
+ const cleanup = () => {
374
+ if (closed) return
375
+ closed = true
376
+ if (debounceTimer) clearTimeout(debounceTimer)
377
+ if (liveDebounceTimer) clearTimeout(liveDebounceTimer)
378
+ if (keepaliveTimer) clearInterval(keepaliveTimer)
379
+ if (jsonlRecheckTimer) clearTimeout(jsonlRecheckTimer)
380
+ try { watcher?.close() } catch { /* ignore */ }
381
+ if (ptyListener && pty) {
382
+ try { pty.off('output', ptyListener) } catch { /* ignore */ }
383
+ }
384
+ try { res.end() } catch { /* ignore */ }
385
+ }
386
+ req.on('close', cleanup)
387
+ req.on('error', cleanup)
388
+
389
+ // 既没有 jsonl 也没有 PTY 订阅能力,才让前端降级去轮询
390
+ if ((initial.source !== 'jsonl' || !initial.filePath) && !hasPty) {
391
+ sendEvent('stream-not-supported', { source: initial.source })
392
+ cleanup()
393
+ return
394
+ }
395
+
396
+ keepaliveTimer = setInterval(() => {
397
+ if (!closed) { try { res.write(': keepalive\n\n') } catch { cleanup() } }
398
+ }, 15_000)
399
+
400
+ const handleJsonlChange = () => {
401
+ if (closed) return
402
+ if (debounceTimer) clearTimeout(debounceTimer)
403
+ debounceTimer = setTimeout(async () => {
404
+ debounceTimer = null
405
+ if (closed) return
406
+ try {
407
+ const parsed = await loadTranscript(loadArgs())
408
+ if (parsed.source !== 'jsonl') return
409
+ const newTotal = parsed.turns.length
410
+ if (newTotal > lastTotal) {
411
+ sendEvent('turn-added', {
412
+ turns: parsed.turns.slice(lastTotal),
413
+ total: newTotal,
414
+ })
415
+ lastTotal = newTotal
416
+ } else if (newTotal < lastTotal) {
417
+ // 文件被重写/截断:重新整体下发
418
+ sendEvent('snapshot', {
419
+ source: 'jsonl',
420
+ turns: parsed.turns,
421
+ total: newTotal,
422
+ session: {
423
+ sessionId: session.sessionId, tool: session.tool,
424
+ nativeSessionId: session.nativeSessionId, status: session.status,
425
+ label: session.label || '',
426
+ startedAt: session.startedAt, completedAt: session.completedAt,
427
+ },
428
+ })
429
+ lastTotal = newTotal
430
+ }
431
+ } catch (e) {
432
+ console.warn('[transcript stream] re-parse failed:', e?.message)
433
+ }
434
+ }, 120)
435
+ }
436
+
437
+ const attachJsonlWatcher = (filePath) => {
438
+ if (closed || watcher) return
439
+ try {
440
+ watcher = fsWatch(filePath, handleJsonlChange)
441
+ watcher.on?.('error', () => cleanup())
442
+ } catch (e) {
443
+ sendEvent('error', { message: `watch failed: ${e.message}` })
444
+ }
445
+ }
446
+
447
+ if (initial.source === 'jsonl' && initial.filePath) {
448
+ attachJsonlWatcher(initial.filePath)
449
+ } else {
450
+ // resume 新 session 时老 nativeSessionId 对应的 jsonl 存在但和新会话无关,
451
+ // loadTranscript 在拿到 'native-session' 事件后会自动指向新路径;这里周期
452
+ // 重试直到新 jsonl 出现,再挂 fsWatch 把完成的 turn 推给前端。
453
+ const recheck = async () => {
454
+ jsonlRecheckTimer = null
455
+ if (closed || watcher) return
456
+ try {
457
+ const parsed = await loadTranscript(loadArgs())
458
+ if (parsed.source === 'jsonl' && parsed.filePath) {
459
+ if (parsed.turns.length > lastTotal) {
460
+ sendEvent('turn-added', {
461
+ turns: parsed.turns.slice(lastTotal),
462
+ total: parsed.turns.length,
463
+ })
464
+ lastTotal = parsed.turns.length
465
+ }
466
+ attachJsonlWatcher(parsed.filePath)
467
+ return
468
+ }
469
+ } catch { /* ignore, just retry */ }
470
+ if (!closed) jsonlRecheckTimer = setTimeout(recheck, 1500)
471
+ }
472
+ jsonlRecheckTimer = setTimeout(recheck, 1500)
473
+ }
474
+
475
+ // ─── 订阅 PTY 输出,边生成边推送 ───
476
+ if (hasPty) {
477
+ const emitLive = async () => {
478
+ if (closed) return
479
+ const live = typeof getLiveSession === 'function' ? getLiveSession(session.sessionId) : null
480
+ if (!live?.outputHistory?.length) return
481
+ if (live.status !== 'running' && live.status !== 'pending_confirm') return
482
+ try {
483
+ const raw = live.outputHistory.join('')
484
+ const content = await renderPtyLogText(raw)
485
+ if (closed || content === lastLiveContent) return
486
+ lastLiveContent = content
487
+ sendEvent('live-output', {
488
+ content,
489
+ timestamp: live.lastOutputAt || Date.now(),
490
+ })
491
+ } catch (e) {
492
+ // 渲染失败就吞掉,不影响 jsonl 主通道
493
+ if (process.env.DEBUG) console.warn('[transcript stream] render live failed:', e?.message)
494
+ }
495
+ }
496
+ ptyListener = (payload) => {
497
+ if (!payload || payload.sessionId !== session.sessionId) return
498
+ if (closed || liveDebounceTimer) return
499
+ // 300ms 节流:xterm 每次重放 outputHistory 有成本;SSE 事件过密前端也会卡顿
500
+ liveDebounceTimer = setTimeout(() => {
501
+ liveDebounceTimer = null
502
+ void emitLive()
503
+ }, 300)
504
+ }
505
+ pty.on('output', ptyListener)
506
+ // 连上来时如果已经在生成(比如刷新页面),立刻推一次当前屏
507
+ setTimeout(() => { void emitLive() }, 0)
508
+ }
509
+ })
510
+
511
+ router.post('/:id/ai-sessions/:sessionId/fork', async (req, res) => {
512
+ try {
513
+ const sourceTodo = db.getTodo(req.params.id)
514
+ if (!sourceTodo) {
515
+ res.status(404).json({ ok: false, error: 'not_found' })
516
+ return
517
+ }
518
+ const session = (sourceTodo.aiSessions || []).find(s => s?.sessionId === req.params.sessionId)
519
+ if (!session) {
520
+ res.status(404).json({ ok: false, error: 'session_not_found' })
521
+ return
522
+ }
523
+ const liveSession = typeof getLiveSession === 'function'
524
+ ? getLiveSession(session.sessionId)
525
+ : null
526
+
527
+ const {
528
+ targetTodoId,
529
+ tool = session.tool,
530
+ newInstruction = '',
531
+ keepLastTurns = 6,
532
+ summarize = true,
533
+ } = req.body || {}
534
+
535
+ if (!SUPPORTED_TOOLS.includes(tool)) {
536
+ res.status(400).json({ ok: false, error: 'invalid tool' })
537
+ return
538
+ }
539
+
540
+ const targetTodo = targetTodoId ? db.getTodo(targetTodoId) : sourceTodo
541
+ if (!targetTodo) {
542
+ res.status(404).json({ ok: false, error: 'target_not_found' })
543
+ return
544
+ }
545
+
546
+ const transcript = await loadTranscript({
547
+ tool: session.tool,
548
+ nativeSessionId: session.nativeSessionId,
549
+ cwd: session.cwd || sourceTodo.workDir || null,
550
+ sessionId: session.sessionId,
551
+ logDir,
552
+ liveOutputHistory: liveSession?.outputHistory || null,
553
+ liveTimestamp: liveSession?.lastOutputAt || Date.now(),
554
+ })
555
+
556
+ const allTurns = transcript.turns || []
557
+ const keep = Math.max(0, Math.min(Number(keepLastTurns) || 0, allTurns.length))
558
+ const tail = keep > 0 ? allTurns.slice(-keep) : []
559
+ const head = keep > 0 ? allTurns.slice(0, -keep) : allTurns.slice()
560
+
561
+ let summary = ''
562
+ if (summarize && head.length > 0) {
563
+ try {
564
+ summary = await summarizeTurns(head, {
565
+ tool,
566
+ tools: typeof getTools === 'function' ? getTools() : undefined,
567
+ })
568
+ } catch (e) {
569
+ console.warn('[fork] summarize failed:', e.message)
570
+ summary = `(自动摘要失败:${e.message})`
571
+ }
572
+ }
573
+
574
+ const parts = []
575
+ parts.push(`# 继续任务:${targetTodo.title}`)
576
+ if (targetTodo.description) parts.push(`## 任务描述\n${targetTodo.description}`)
577
+ if (summary) parts.push(`## 历史会话摘要\n${summary}`)
578
+ if (tail.length > 0) {
579
+ const tailText = tail.map(t => {
580
+ const role = t.role === 'user' ? '用户' : t.role === 'assistant' ? 'AI' : t.role
581
+ return `【${role}】${String(t.content || '').slice(0, 2000)}`
582
+ }).join('\n\n')
583
+ parts.push(`## 最近 ${tail.length} 轮原始对话\n${tailText}`)
584
+ }
585
+ if (newInstruction && newInstruction.trim()) {
586
+ parts.push(`## 新指令\n${newInstruction.trim()}`)
587
+ } else {
588
+ parts.push(`## 新指令\n请在上面的上下文基础上继续推进。`)
589
+ }
590
+
591
+ const prompt = parts.join('\n\n')
592
+
593
+ res.json({
594
+ ok: true,
595
+ prompt,
596
+ targetTodoId: targetTodo.id,
597
+ tool,
598
+ cwd: targetTodo.workDir || session.cwd || null,
599
+ sourceSessionId: session.sessionId,
600
+ summaryUsed: !!summary,
601
+ tailCount: tail.length,
602
+ headCount: head.length,
603
+ })
604
+ } catch (e) {
605
+ console.error('[fork]', e)
606
+ res.status(500).json({ ok: false, error: e.message })
607
+ }
608
+ })
609
+
610
+ router.get('/:id/export.md', async (req, res) => {
611
+ try {
612
+ const turns = ['summary', 'full', 'none'].includes(String(req.query.turns)) ? String(req.query.turns) : 'summary'
613
+ const turnLimit = req.query.turnLimit ? Math.min(Math.max(Number(req.query.turnLimit) || 0, 1), 500) : 80
614
+ const pricing = typeof getPricing === 'function' ? getPricing() : undefined
615
+ const report = await buildTodoExport(db, req.params.id, { turns, turnLimit, pricing })
616
+ if (!report) {
617
+ res.status(404).json({ ok: false, error: 'not_found' })
618
+ return
619
+ }
620
+ const md = renderTodoMarkdown(report)
621
+ res.set('Content-Type', 'text/markdown; charset=utf-8')
622
+ res.set('Content-Disposition', `inline; filename="todo-${req.params.id}.md"`)
623
+ res.send(md)
624
+ } catch (e) {
625
+ console.error('[export.md]', e)
626
+ res.status(500).json({ ok: false, error: e.message })
627
+ }
628
+ })
629
+
630
+ router.get('/:id/export.json', async (req, res) => {
631
+ try {
632
+ const turns = ['summary', 'full', 'none'].includes(String(req.query.turns)) ? String(req.query.turns) : 'summary'
633
+ const turnLimit = req.query.turnLimit ? Math.min(Math.max(Number(req.query.turnLimit) || 0, 1), 500) : 80
634
+ const pricing = typeof getPricing === 'function' ? getPricing() : undefined
635
+ const report = await buildTodoExport(db, req.params.id, { turns, turnLimit, pricing })
636
+ if (!report) {
637
+ res.status(404).json({ ok: false, error: 'not_found' })
638
+ return
639
+ }
640
+ const md = renderTodoMarkdown(report)
641
+ res.json({ ok: true, markdown: md, todo: { id: report.todo.id, title: report.todo.title } })
642
+ } catch (e) {
643
+ console.error('[export.json]', e)
644
+ res.status(500).json({ ok: false, error: e.message })
645
+ }
646
+ })
647
+
648
+ return router
649
+ }