heyquilt 0.0.1 → 0.3.106

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 (221) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +633 -2
  3. package/RELEASES.md +862 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +849 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +591 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +187 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +177 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +110 -0
  19. package/src/ai-vm.js +522 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +136 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +133 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1219 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +545 -0
  40. package/src/api/relay-machines.js +92 -0
  41. package/src/api/releases.js +87 -0
  42. package/src/api/routes/access-types.js +54 -0
  43. package/src/api/routes/agent-invites.js +115 -0
  44. package/src/api/routes/agents.js +252 -0
  45. package/src/api/routes/ai-vm.js +495 -0
  46. package/src/api/routes/github.js +380 -0
  47. package/src/api/routes/grants.js +70 -0
  48. package/src/api/routes/invites.js +188 -0
  49. package/src/api/routes/issues.js +47 -0
  50. package/src/api/routes/join.js +167 -0
  51. package/src/api/routes/members.js +110 -0
  52. package/src/api/routes/orgs.js +179 -0
  53. package/src/api/routes/relay-cluster.js +90 -0
  54. package/src/api/routes/relay.js +76 -0
  55. package/src/api/routes/secrets.js +448 -0
  56. package/src/api/routes/session-invites.js +119 -0
  57. package/src/api/routes/sessions.js +92 -0
  58. package/src/api/routes/teams.js +132 -0
  59. package/src/api/routes/workspace-agents.js +332 -0
  60. package/src/api/routes/workspace-files.js +277 -0
  61. package/src/api/routes/workspace-invites.js +196 -0
  62. package/src/api/routes/workspaces.js +232 -0
  63. package/src/api/sandbox-keys.js +57 -0
  64. package/src/api/secrets-access.js +93 -0
  65. package/src/api/server.js +654 -0
  66. package/src/api/slugs.js +20 -0
  67. package/src/api/supabase-store.js +875 -0
  68. package/src/api/team-access.js +30 -0
  69. package/src/api/tokens.js +31 -0
  70. package/src/api/workspace-access.js +62 -0
  71. package/src/api/workspace-reach.js +60 -0
  72. package/src/autowork-tools.js +166 -0
  73. package/src/autowork.js +375 -0
  74. package/src/blobstore.js +99 -0
  75. package/src/boxes.js +37 -0
  76. package/src/branchdocs.js +235 -0
  77. package/src/branches.js +194 -0
  78. package/src/catchup.js +111 -0
  79. package/src/chat-archive.js +85 -0
  80. package/src/chat-links.js +923 -0
  81. package/src/clash.js +128 -0
  82. package/src/cloud-ai.js +50 -0
  83. package/src/commit-request.js +59 -0
  84. package/src/commit.js +213 -0
  85. package/src/connection.js +569 -0
  86. package/src/control.js +248 -0
  87. package/src/conversation.js +125 -0
  88. package/src/doctor.js +156 -0
  89. package/src/duties.js +303 -0
  90. package/src/editors.js +228 -0
  91. package/src/fsutil.js +97 -0
  92. package/src/git-hook.js +48 -0
  93. package/src/git.js +147 -0
  94. package/src/github-commit.js +320 -0
  95. package/src/gitignore.js +49 -0
  96. package/src/gitstate.js +924 -0
  97. package/src/history.js +307 -0
  98. package/src/hooks.js +371 -0
  99. package/src/hosted-agents.js +24 -0
  100. package/src/hosted-client.js +112 -0
  101. package/src/identity.js +99 -0
  102. package/src/inbox.js +237 -0
  103. package/src/integrations.js +221 -0
  104. package/src/key-cli.js +115 -0
  105. package/src/largefiles.js +91 -0
  106. package/src/legacy.js +25 -0
  107. package/src/mcp-proxy.js +45 -0
  108. package/src/mcp-room.js +32 -0
  109. package/src/mcp.js +1258 -0
  110. package/src/merge3.js +240 -0
  111. package/src/merges.js +148 -0
  112. package/src/pass-source.js +183 -0
  113. package/src/passes.js +64 -0
  114. package/src/pathrules.js +134 -0
  115. package/src/persona.js +101 -0
  116. package/src/presence.js +360 -0
  117. package/src/private-file.js +41 -0
  118. package/src/procs.js +70 -0
  119. package/src/project-rules.js +178 -0
  120. package/src/protocol.js +92 -0
  121. package/src/quiet-warnings.js +9 -0
  122. package/src/relay-ai-vm.js +82 -0
  123. package/src/relay-ask.js +141 -0
  124. package/src/relay-ci.js +111 -0
  125. package/src/relay-clash.js +214 -0
  126. package/src/relay-cluster.js +454 -0
  127. package/src/relay-commit.js +887 -0
  128. package/src/relay-filekey.js +46 -0
  129. package/src/relay-git.js +274 -0
  130. package/src/relay-githttp.js +166 -0
  131. package/src/relay-github.js +109 -0
  132. package/src/relay-lease.js +137 -0
  133. package/src/relay-lfs.js +37 -0
  134. package/src/relay-mcp.js +1691 -0
  135. package/src/relay-metrics.js +140 -0
  136. package/src/relay-outbound.js +1273 -0
  137. package/src/relay-sync.js +716 -0
  138. package/src/relay-turn-sync.js +506 -0
  139. package/src/relay-upstream.js +804 -0
  140. package/src/relay-webhooks.js +111 -0
  141. package/src/releases.js +138 -0
  142. package/src/report.js +129 -0
  143. package/src/runner.js +307 -0
  144. package/src/same-edit.js +18 -0
  145. package/src/secret-import.js +55 -0
  146. package/src/secret-names.js +26 -0
  147. package/src/secret-run.js +80 -0
  148. package/src/secrets-client.js +56 -0
  149. package/src/server.js +4214 -0
  150. package/src/session-access.js +170 -0
  151. package/src/session-name.js +15 -0
  152. package/src/session.js +7446 -0
  153. package/src/settings.js +46 -0
  154. package/src/setup.js +184 -0
  155. package/src/shell-path.js +46 -0
  156. package/src/status.js +197 -0
  157. package/src/summarize.js +109 -0
  158. package/src/sync-words.js +42 -0
  159. package/src/task-comments.js +92 -0
  160. package/src/tasks.js +670 -0
  161. package/src/textdiff.js +20 -0
  162. package/src/turn-sync-plan.js +145 -0
  163. package/src/turn-sync.js +212 -0
  164. package/src/ui/access-form.js +37 -0
  165. package/src/ui/agent-env-key.js +76 -0
  166. package/src/ui/agent-guide.js +127 -0
  167. package/src/ui/agent-kinds.js +175 -0
  168. package/src/ui/agent-place.js +265 -0
  169. package/src/ui/app.css +2494 -0
  170. package/src/ui/app.js +546 -0
  171. package/src/ui/board.js +465 -0
  172. package/src/ui/boxes.js +76 -0
  173. package/src/ui/branches.js +111 -0
  174. package/src/ui/catchup.js +77 -0
  175. package/src/ui/changes.js +159 -0
  176. package/src/ui/chat.js +266 -0
  177. package/src/ui/cloud-ai-model.js +108 -0
  178. package/src/ui/cloud-ai.js +221 -0
  179. package/src/ui/common.js +433 -0
  180. package/src/ui/csv.js +21 -0
  181. package/src/ui/diffview.js +74 -0
  182. package/src/ui/feed-convs.js +39 -0
  183. package/src/ui/feed.js +174 -0
  184. package/src/ui/files.js +579 -0
  185. package/src/ui/fileview.js +150 -0
  186. package/src/ui/git-rule.js +60 -0
  187. package/src/ui/home.js +1322 -0
  188. package/src/ui/index.html +16 -0
  189. package/src/ui/invite.js +97 -0
  190. package/src/ui/invites.js +79 -0
  191. package/src/ui/loom-model.js +516 -0
  192. package/src/ui/loom.css +365 -0
  193. package/src/ui/loom.js +786 -0
  194. package/src/ui/mark.js +104 -0
  195. package/src/ui/rail-model.js +75 -0
  196. package/src/ui/rail.js +173 -0
  197. package/src/ui/releases.js +182 -0
  198. package/src/ui/sandbox-keys-model.js +53 -0
  199. package/src/ui/sandbox-keys.js +132 -0
  200. package/src/ui/schedule.js +156 -0
  201. package/src/ui/secret-rules.js +255 -0
  202. package/src/ui/secrets-view.js +162 -0
  203. package/src/ui/secrets.js +283 -0
  204. package/src/ui/session.js +3292 -0
  205. package/src/ui/signin.js +75 -0
  206. package/src/ui/sounds.js +110 -0
  207. package/src/ui/tool-logo.js +86 -0
  208. package/src/ui/tree.js +281 -0
  209. package/src/ui/workspaces.js +547 -0
  210. package/src/ui-server.js +1381 -0
  211. package/src/update-check.js +67 -0
  212. package/src/upstream.js +192 -0
  213. package/src/vault/crypto.js +96 -0
  214. package/src/vault/kms.js +61 -0
  215. package/src/vault/main.js +33 -0
  216. package/src/vault/server.js +279 -0
  217. package/src/vault/sigv4.js +38 -0
  218. package/src/webhooks.js +235 -0
  219. package/src/workspace-cli.js +62 -0
  220. package/src/workspace-tools.js +340 -0
  221. package/index.js +0 -2
@@ -0,0 +1,187 @@
1
+ // Reads Claude Code conversation transcripts for one project folder and turns
2
+ // them into feed entries. Transcripts are JSONL files under
3
+ // ~/.claude/projects/<slug>/<sessionId>.jsonl, appended to as the chat goes on.
4
+ import fs from 'node:fs'
5
+ import os from 'node:os'
6
+ import path from 'node:path'
7
+ import { describeAction } from './actions.js'
8
+ import { capText } from './common.js'
9
+
10
+ const POLL_MS = 750
11
+ const IDLE_AFTER_MS = 2 * 60 * 1000
12
+ const BACKFILL_MS = 60 * 60 * 1000
13
+ const BACKFILL_ENTRIES = 50
14
+ const TITLE_CHARS = 80
15
+
16
+ export const slugFor = (dir) => path.resolve(dir).replace(/[^A-Za-z0-9]/g, '-')
17
+
18
+ export function claudeProjectsDir (home = os.homedir()) {
19
+ const base = process.env.CLAUDE_CONFIG_DIR || path.join(home, '.claude')
20
+ return path.join(base, 'projects')
21
+ }
22
+
23
+ /**
24
+ * @param {object} o
25
+ * @param {string} o.dir synced folder
26
+ * @param {(entries: object[]) => void} o.onEntries
27
+ * @param {(state: object) => void} o.onState
28
+ * @param {(line: string) => void} [o.onLog]
29
+ * @param {string} [o.home]
30
+ * @param {string} [o.chatDir] folder the AI works from, if not `dir` (an agent that synced into a subfolder)
31
+ */
32
+ export function startClaudeCodeReader ({ dir, chatDir, onEntries, onState, onLog = () => {}, home, pollMs = POLL_MS, now = Date.now }) {
33
+ dir = path.resolve(dir)
34
+ const chatRoot = path.resolve(chatDir || dir)
35
+ const root = claudeProjectsDir(home)
36
+ const slug = slugFor(chatRoot)
37
+ const files = new Map() // path -> { offset, partial, seen: Set<path> }
38
+ let status = null
39
+ // Each conversation mid-turn, by its transcript file, with when it last wrote: the tool is working
40
+ // while any of them is, so one conversation ending its turn never makes another's edits look like a
41
+ // person's (they'd go unboxed). A conversation silent for IDLE_AFTER_MS counts as done.
42
+ const midTurn = new Map()
43
+ const settle = () => setStatus(midTurn.size ? 'working' : 'idle')
44
+ let stopped = false
45
+ let failed = false
46
+ let first = true
47
+
48
+ const setStatus = (s) => {
49
+ if (s === status) return
50
+ status = s
51
+ onState({ tool: 'Claude Code', status: s })
52
+ }
53
+ setStatus('idle')
54
+
55
+ const inside = (cwd) => typeof cwd === 'string' && (cwd === chatRoot || cwd.startsWith(chatRoot + path.sep))
56
+
57
+ function parseLine (line, file, st) {
58
+ let j
59
+ try { j = JSON.parse(line) } catch { return null }
60
+ // The conversation's title, as the person sees it in Claude Code's list (its own name for the
61
+ // chat, or one they gave it). These lines carry no folder, so they're read before the check.
62
+ // Entries from here on carry it, so the Loom and the feed can name the conversation the same way.
63
+ if (j && (j.type === 'custom-title' || j.type === 'agent-name')) {
64
+ const t = String(j.customTitle || j.agentName || '').replace(/\s+/g, ' ').trim().slice(0, TITLE_CHARS)
65
+ if (t && (j.type === 'custom-title' || !st.title)) st.title = t
66
+ return null
67
+ }
68
+ if (!j || j.isSidechain || !inside(j.cwd)) return null
69
+ const ts = Date.parse(j.timestamp) || now()
70
+ const conv = j.sessionId || path.basename(file, '.jsonl')
71
+ const out = []
72
+ const msg = j.message || {}
73
+
74
+ if (j.type === 'user' && !j.isMeta) {
75
+ const text = typeof msg.content === 'string'
76
+ ? msg.content
77
+ : Array.isArray(msg.content) ? msg.content.filter((b) => b && b.type === 'text').map((b) => b.text).join('\n\n') : ''
78
+ if (text.trim() && !isSystemText(text)) {
79
+ out.push({ id: j.uuid, tool: 'Claude Code', conv, kind: 'prompt', text: capText(text.trim()), ts })
80
+ }
81
+ } else if (j.type === 'assistant' && Array.isArray(msg.content)) {
82
+ msg.content.forEach((b, i) => {
83
+ if (!b) return
84
+ const id = `${j.uuid}:${i}`
85
+ if (b.type === 'text' && b.text && b.text.trim()) {
86
+ out.push({ id, tool: 'Claude Code', conv, kind: 'reply', text: capText(b.text.trim()), ts })
87
+ } else if (b.type === 'tool_use') {
88
+ const target = b.input && (b.input.file_path || b.input.notebook_path)
89
+ const abs = target ? path.resolve(dir, target) : null
90
+ const text = describeAction(b.name, b.input, dir, { existed: (p) => st.seen.has(p) })
91
+ if (abs) st.seen.add(abs)
92
+ out.push({ id, tool: 'Claude Code', conv, kind: 'action', text, ts })
93
+ }
94
+ })
95
+ }
96
+ if (st.title) for (const e of out) e.title = st.title
97
+ return { entries: out, endTurn: j.type === 'assistant' && msg.stop_reason === 'end_turn', relevant: j.type === 'user' || j.type === 'assistant' }
98
+ }
99
+
100
+ function readFile (file, backfill) {
101
+ let st = files.get(file)
102
+ const size = fs.statSync(file).size
103
+ if (!st) {
104
+ st = { offset: 0, partial: '', seen: new Set() }
105
+ files.set(file, st)
106
+ if (!backfill) {
107
+ // Older conversation: only watch it from here on.
108
+ st.offset = size
109
+ return []
110
+ }
111
+ }
112
+ if (size < st.offset) { st.offset = 0; st.partial = '' } // truncated or replaced
113
+ if (size === st.offset) return []
114
+ const fd = fs.openSync(file, 'r')
115
+ let chunk
116
+ try {
117
+ const buf = Buffer.alloc(size - st.offset)
118
+ fs.readSync(fd, buf, 0, buf.length, st.offset)
119
+ chunk = buf.toString('utf8')
120
+ } finally {
121
+ fs.closeSync(fd)
122
+ }
123
+ st.offset = size
124
+ const text = st.partial + chunk
125
+ const lines = text.split('\n')
126
+ st.partial = lines.pop() // incomplete last line (no newline yet)
127
+ const entries = []
128
+ for (const line of lines) {
129
+ if (!line.trim()) continue
130
+ const r = parseLine(line, file, st)
131
+ if (!r) continue
132
+ entries.push(...r.entries)
133
+ if (r.relevant) {
134
+ if (!backfill) {
135
+ if (r.endTurn) midTurn.delete(file); else midTurn.set(file, now())
136
+ settle()
137
+ }
138
+ }
139
+ }
140
+ return backfill ? entries.slice(-BACKFILL_ENTRIES) : entries
141
+ }
142
+
143
+ function tick () {
144
+ if (stopped) return
145
+ try {
146
+ let dirs = []
147
+ try {
148
+ dirs = fs.readdirSync(root, { withFileTypes: true })
149
+ .filter((d) => d.isDirectory() && d.name.startsWith(slug))
150
+ .map((d) => path.join(root, d.name))
151
+ } catch {} // Claude Code not used yet here
152
+ const out = []
153
+ for (const d of dirs) {
154
+ let names = []
155
+ try { names = fs.readdirSync(d).filter((n) => n.endsWith('.jsonl')) } catch { continue }
156
+ for (const n of names) {
157
+ const file = path.join(d, n)
158
+ const known = files.has(file)
159
+ let backfill = false
160
+ if (!known) {
161
+ // On startup, pick up recent conversations; after that, any new file is live.
162
+ backfill = !first || now() - fs.statSync(file).mtimeMs < BACKFILL_MS
163
+ }
164
+ try { out.push(...readFile(file, backfill)) } catch (err) {
165
+ if (!failed) { failed = true; onLog(`Claude Code feed: could not read ${n}: ${err.message}`) }
166
+ }
167
+ }
168
+ }
169
+ first = false
170
+ for (const [f, at] of midTurn) if (now() - at > IDLE_AFTER_MS) midTurn.delete(f)
171
+ settle()
172
+ if (out.length) onEntries(out.sort((a, b) => a.ts - b.ts))
173
+ } catch (err) {
174
+ if (!failed) { failed = true; onLog(`Claude Code feed error: ${err.message}`) }
175
+ }
176
+ }
177
+
178
+ tick()
179
+ const timer = setInterval(tick, pollMs)
180
+ return { stop () { stopped = true; clearInterval(timer) }, tick }
181
+ }
182
+
183
+ // Slash commands, hook output and similar plumbing that show up as "user" lines.
184
+ function isSystemText (text) {
185
+ return /^\s*<(command-name|command-message|command-args|local-command-stdout|local-command-stderr|system-reminder|user-memory-input|bash-input|bash-stdout|bash-stderr|task-notification)\b/.test(text) ||
186
+ text.startsWith('Caveat: The messages below were generated by the user while running local commands')
187
+ }
@@ -0,0 +1,21 @@
1
+ export const MAX_ENTRY_CHARS = 8000
2
+
3
+ export function capText (text) {
4
+ text = String(text ?? '')
5
+ return text.length > MAX_ENTRY_CHARS ? text.slice(0, MAX_ENTRY_CHARS) + '…(truncated)' : text
6
+ }
7
+
8
+ /** "claude-code" -> "Claude Code" etc., from the MCP client's name or an agent provider. */
9
+ export function toolLabel (client) {
10
+ const n = String(client || '')
11
+ if (/claude/i.test(n)) return 'Claude Code'
12
+ if (/cursor/i.test(n)) return 'Cursor'
13
+ if (/codex/i.test(n)) return 'Codex'
14
+ if (/windsurf|codeium/i.test(n)) return 'Windsurf'
15
+ if (/zed/i.test(n)) return 'Zed'
16
+ // Model providers (agents often register these, not the host IDE).
17
+ if (/\bxai\b|grok/i.test(n)) return 'xAI'
18
+ if (/chatgpt|openai/i.test(n)) return 'Codex'
19
+ if (/gemini/i.test(n)) return 'Other'
20
+ return n || 'AI agent'
21
+ }
@@ -0,0 +1,177 @@
1
+ // Reads Cursor agent transcripts for one project folder. Current Cursor stores
2
+ // the agent chat as JSONL under ~/.cursor/projects/<slug>/agent-transcripts/,
3
+ // not in the composer database the older reader polls. The slug is Cursor's:
4
+ // any character that is not a letter or digit becomes a hyphen.
5
+ import fs from 'node:fs'
6
+ import os from 'node:os'
7
+ import path from 'node:path'
8
+ import { describeAction } from './actions.js'
9
+ import { capText } from './common.js'
10
+
11
+ const POLL_MS = 750
12
+ const IDLE_AFTER_MS = 2 * 60 * 1000
13
+ const BACKFILL_MS = 60 * 60 * 1000
14
+ const BACKFILL_ENTRIES = 50
15
+
16
+ export function cursorProjectsDir (home = os.homedir()) {
17
+ return path.join(home, '.cursor', 'projects')
18
+ }
19
+
20
+ /** Cursor's ~/.cursor/projects folder name for an absolute path.
21
+ * Every character that is not a letter or digit becomes a hyphen, runs of
22
+ * hyphens collapse, and hyphens at either end are removed.
23
+ * `C:\Quilt Test (1)` -> `C-Quilt-Test-1`, `/Users/a/my app` -> `Users-a-my-app`. */
24
+ export function slugCursorPath (absPath) {
25
+ return String(absPath).replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '')
26
+ }
27
+
28
+ export function cursorProjectSlug (dir) {
29
+ return slugCursorPath(path.resolve(dir))
30
+ }
31
+
32
+ export function projectMatches (name, dir) {
33
+ const slug = cursorProjectSlug(dir)
34
+ if (!slug) return false
35
+ return process.platform === 'win32' ? name.toLowerCase() === slug.toLowerCase() : name === slug
36
+ }
37
+
38
+ /** Cursor wraps the typed message in timestamp and user_query tags. Share only the message. */
39
+ export function cursorPromptText (text) {
40
+ const raw = String(text || '')
41
+ const queries = [...raw.matchAll(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/gi)].map((m) => m[1].trim()).filter(Boolean)
42
+ if (queries.length) return queries.join('\n\n')
43
+ return raw.replace(/<timestamp>[\s\S]*?<\/timestamp>/gi, '').trim()
44
+ }
45
+
46
+ export function startCursorTranscriptReader ({ dir, onEntries, onState, onLog = () => {}, projectsDir, pollMs = POLL_MS, now = Date.now }) {
47
+ dir = path.resolve(dir)
48
+ const root = projectsDir || cursorProjectsDir()
49
+ const files = new Map() // path -> { offset, partial, lineNo }
50
+ let status = null
51
+ // Each conversation mid-turn, by its transcript file, with when it last wrote: the tool is working
52
+ // while any of them is, so one conversation ending its turn never makes another's edits look like a
53
+ // person's (they'd go unboxed). A conversation silent for IDLE_AFTER_MS counts as done.
54
+ const midTurn = new Map()
55
+ const settle = () => setStatus(midTurn.size ? 'working' : 'idle')
56
+ let stopped = false
57
+ let failed = false
58
+ let first = true
59
+
60
+ const setStatus = (s) => {
61
+ if (s === status) return
62
+ status = s
63
+ onState({ tool: 'Cursor', status: s })
64
+ }
65
+ setStatus('idle')
66
+
67
+ function parseLine (line, conv, lineNo) {
68
+ let j
69
+ try { j = JSON.parse(line) } catch { return null }
70
+ const ts = now() + lineNo
71
+ const content = j.message && Array.isArray(j.message.content) ? j.message.content : []
72
+ const out = []
73
+ if (j.role === 'user') {
74
+ const text = cursorPromptText(content.filter((b) => b && b.type === 'text' && b.text).map((b) => b.text).join('\n\n'))
75
+ if (text.trim()) out.push({ id: `${conv}:${lineNo}`, tool: 'Cursor', conv, kind: 'prompt', text: capText(text.trim()), ts })
76
+ } else if (j.role === 'assistant') {
77
+ content.forEach((b, i) => {
78
+ if (!b || b.type === 'thinking') return
79
+ const id = `${conv}:${lineNo}:${i}`
80
+ if (b.type === 'text' && b.text && b.text.trim()) out.push({ id, tool: 'Cursor', conv, kind: 'reply', text: capText(b.text.trim()), ts })
81
+ else if (b.type === 'tool_use' && b.name) out.push({ id, tool: 'Cursor', conv, kind: 'action', text: describeAction(b.name, b.input, dir), ts })
82
+ })
83
+ }
84
+ return { entries: out, endTurn: j.type === 'turn_ended', relevant: j.role === 'user' || j.role === 'assistant' || j.type === 'turn_ended' }
85
+ }
86
+
87
+ function readFile (file, backfill) {
88
+ let st = files.get(file)
89
+ const size = fs.statSync(file).size
90
+ if (!st) {
91
+ st = { offset: 0, partial: '', lineNo: 0 }
92
+ files.set(file, st)
93
+ if (!backfill) { st.offset = size; return [] }
94
+ }
95
+ if (size < st.offset) { st.offset = 0; st.partial = ''; st.lineNo = 0 }
96
+ if (size === st.offset) return []
97
+ const fd = fs.openSync(file, 'r')
98
+ let chunk
99
+ try {
100
+ const buf = Buffer.alloc(size - st.offset)
101
+ fs.readSync(fd, buf, 0, buf.length, st.offset)
102
+ chunk = buf.toString('utf8')
103
+ } finally {
104
+ fs.closeSync(fd)
105
+ }
106
+ st.offset = size
107
+ const text = st.partial + chunk
108
+ const lines = text.split('\n')
109
+ st.partial = lines.pop()
110
+ const conv = path.basename(file, '.jsonl')
111
+ const entries = []
112
+ for (const line of lines) {
113
+ if (!line.trim()) continue
114
+ st.lineNo++
115
+ const r = parseLine(line, conv, st.lineNo)
116
+ if (!r) continue
117
+ entries.push(...r.entries)
118
+ if (r.relevant) {
119
+ {
120
+ if (r.endTurn) midTurn.delete(file); else midTurn.set(file, now())
121
+ settle()
122
+ }
123
+ }
124
+ }
125
+ return backfill ? entries.slice(-BACKFILL_ENTRIES) : entries
126
+ }
127
+
128
+ function transcripts (projectDir) {
129
+ const base = path.join(projectDir, 'agent-transcripts')
130
+ const out = []
131
+ const walk = (d, depth) => {
132
+ if (depth > 3) return
133
+ let entries = []
134
+ try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { return }
135
+ for (const e of entries) {
136
+ const p = path.join(d, e.name)
137
+ if (e.isDirectory()) walk(p, depth + 1)
138
+ else if (e.name.endsWith('.jsonl')) out.push(p)
139
+ }
140
+ }
141
+ walk(base, 0)
142
+ return out
143
+ }
144
+
145
+ function tick () {
146
+ if (stopped) return
147
+ try {
148
+ let dirs = []
149
+ try {
150
+ dirs = fs.readdirSync(root, { withFileTypes: true })
151
+ .filter((d) => d.isDirectory() && projectMatches(d.name, dir))
152
+ .map((d) => path.join(root, d.name))
153
+ } catch {}
154
+ const out = []
155
+ for (const d of dirs) {
156
+ for (const file of transcripts(d)) {
157
+ const known = files.has(file)
158
+ let backfill = false
159
+ if (!known) backfill = !first || now() - fs.statSync(file).mtimeMs < BACKFILL_MS
160
+ try { out.push(...readFile(file, backfill)) } catch (err) {
161
+ if (!failed) { failed = true; onLog(`Cursor feed: could not read ${path.basename(file)}: ${err.message}`) }
162
+ }
163
+ }
164
+ }
165
+ first = false
166
+ for (const [f, at] of midTurn) if (now() - at > IDLE_AFTER_MS) midTurn.delete(f)
167
+ settle()
168
+ if (out.length) onEntries(out)
169
+ } catch (err) {
170
+ if (!failed) { failed = true; onLog(`Cursor feed error: ${err.message}`) }
171
+ }
172
+ }
173
+
174
+ tick()
175
+ const timer = setInterval(tick, pollMs)
176
+ return { stop () { stopped = true; clearInterval(timer) } }
177
+ }