heyquilt 0.0.1 → 0.3.90

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 (204) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +630 -2
  3. package/RELEASES.md +733 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +816 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +563 -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 +180 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +170 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +98 -0
  19. package/src/ai-vm.js +380 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +130 -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 +76 -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 +1083 -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 +251 -0
  40. package/src/api/relay-machines.js +60 -0
  41. package/src/api/routes/access-types.js +54 -0
  42. package/src/api/routes/agent-invites.js +115 -0
  43. package/src/api/routes/agents.js +157 -0
  44. package/src/api/routes/ai-vm.js +369 -0
  45. package/src/api/routes/github.js +380 -0
  46. package/src/api/routes/grants.js +70 -0
  47. package/src/api/routes/invites.js +188 -0
  48. package/src/api/routes/issues.js +47 -0
  49. package/src/api/routes/join.js +167 -0
  50. package/src/api/routes/members.js +110 -0
  51. package/src/api/routes/orgs.js +177 -0
  52. package/src/api/routes/relay-cluster.js +90 -0
  53. package/src/api/routes/relay.js +76 -0
  54. package/src/api/routes/session-invites.js +119 -0
  55. package/src/api/routes/sessions.js +92 -0
  56. package/src/api/routes/teams.js +132 -0
  57. package/src/api/routes/workspace-agents.js +332 -0
  58. package/src/api/routes/workspace-files.js +277 -0
  59. package/src/api/routes/workspace-invites.js +196 -0
  60. package/src/api/routes/workspaces.js +225 -0
  61. package/src/api/server.js +623 -0
  62. package/src/api/slugs.js +20 -0
  63. package/src/api/supabase-store.js +765 -0
  64. package/src/api/team-access.js +30 -0
  65. package/src/api/tokens.js +31 -0
  66. package/src/api/workspace-access.js +62 -0
  67. package/src/api/workspace-reach.js +60 -0
  68. package/src/autowork-tools.js +166 -0
  69. package/src/autowork.js +387 -0
  70. package/src/blobstore.js +99 -0
  71. package/src/boxes.js +37 -0
  72. package/src/branchdocs.js +235 -0
  73. package/src/branches.js +194 -0
  74. package/src/catchup.js +111 -0
  75. package/src/chat-archive.js +85 -0
  76. package/src/chat-links.js +923 -0
  77. package/src/clash.js +140 -0
  78. package/src/cloud-ai.js +50 -0
  79. package/src/commit-request.js +59 -0
  80. package/src/commit.js +213 -0
  81. package/src/connection.js +569 -0
  82. package/src/control.js +245 -0
  83. package/src/conversation.js +125 -0
  84. package/src/doctor.js +156 -0
  85. package/src/duties.js +303 -0
  86. package/src/editors.js +228 -0
  87. package/src/fsutil.js +87 -0
  88. package/src/git-hook.js +48 -0
  89. package/src/git.js +147 -0
  90. package/src/github-commit.js +320 -0
  91. package/src/gitignore.js +49 -0
  92. package/src/gitstate.js +900 -0
  93. package/src/history.js +307 -0
  94. package/src/hooks.js +371 -0
  95. package/src/hosted-agents.js +24 -0
  96. package/src/hosted-client.js +112 -0
  97. package/src/identity.js +99 -0
  98. package/src/inbox.js +237 -0
  99. package/src/integrations.js +221 -0
  100. package/src/key-cli.js +115 -0
  101. package/src/largefiles.js +91 -0
  102. package/src/legacy.js +25 -0
  103. package/src/mcp-proxy.js +40 -0
  104. package/src/mcp-room.js +32 -0
  105. package/src/mcp.js +1261 -0
  106. package/src/merge3.js +158 -0
  107. package/src/merges.js +148 -0
  108. package/src/pass-source.js +176 -0
  109. package/src/passes.js +64 -0
  110. package/src/pathrules.js +127 -0
  111. package/src/persona.js +101 -0
  112. package/src/presence.js +360 -0
  113. package/src/private-file.js +41 -0
  114. package/src/procs.js +70 -0
  115. package/src/project-rules.js +178 -0
  116. package/src/protocol.js +92 -0
  117. package/src/quiet-warnings.js +9 -0
  118. package/src/relay-ai-vm.js +82 -0
  119. package/src/relay-ask.js +141 -0
  120. package/src/relay-ci.js +111 -0
  121. package/src/relay-clash.js +597 -0
  122. package/src/relay-cluster.js +454 -0
  123. package/src/relay-commit.js +887 -0
  124. package/src/relay-filekey.js +46 -0
  125. package/src/relay-git.js +274 -0
  126. package/src/relay-githttp.js +137 -0
  127. package/src/relay-github.js +109 -0
  128. package/src/relay-lease.js +137 -0
  129. package/src/relay-lfs.js +37 -0
  130. package/src/relay-mcp.js +1679 -0
  131. package/src/relay-metrics.js +75 -0
  132. package/src/relay-outbound.js +1144 -0
  133. package/src/relay-sync.js +719 -0
  134. package/src/relay-turn-sync.js +307 -0
  135. package/src/relay-upstream.js +934 -0
  136. package/src/relay-webhooks.js +111 -0
  137. package/src/releases.js +131 -0
  138. package/src/report.js +129 -0
  139. package/src/runner.js +306 -0
  140. package/src/same-edit.js +18 -0
  141. package/src/server.js +4187 -0
  142. package/src/session-access.js +170 -0
  143. package/src/session-name.js +15 -0
  144. package/src/session.js +7361 -0
  145. package/src/settings.js +46 -0
  146. package/src/setup.js +184 -0
  147. package/src/shell-path.js +46 -0
  148. package/src/status.js +199 -0
  149. package/src/summarize.js +109 -0
  150. package/src/sync-words.js +42 -0
  151. package/src/task-comments.js +92 -0
  152. package/src/tasks.js +670 -0
  153. package/src/textdiff.js +20 -0
  154. package/src/turn-sync-plan.js +142 -0
  155. package/src/turn-sync.js +191 -0
  156. package/src/ui/access-form.js +37 -0
  157. package/src/ui/agent-env-key.js +76 -0
  158. package/src/ui/agent-guide.js +127 -0
  159. package/src/ui/agent-kinds.js +175 -0
  160. package/src/ui/agent-place.js +265 -0
  161. package/src/ui/app.css +2309 -0
  162. package/src/ui/app.js +542 -0
  163. package/src/ui/board.js +465 -0
  164. package/src/ui/boxes.js +76 -0
  165. package/src/ui/branches.js +111 -0
  166. package/src/ui/catchup.js +77 -0
  167. package/src/ui/changes.js +159 -0
  168. package/src/ui/chat.js +266 -0
  169. package/src/ui/cloud-ai-model.js +107 -0
  170. package/src/ui/cloud-ai.js +221 -0
  171. package/src/ui/common.js +431 -0
  172. package/src/ui/csv.js +21 -0
  173. package/src/ui/diffview.js +74 -0
  174. package/src/ui/feed-convs.js +39 -0
  175. package/src/ui/feed.js +174 -0
  176. package/src/ui/files.js +579 -0
  177. package/src/ui/fileview.js +123 -0
  178. package/src/ui/git-rule.js +60 -0
  179. package/src/ui/home.js +1323 -0
  180. package/src/ui/index.html +16 -0
  181. package/src/ui/invite.js +97 -0
  182. package/src/ui/invites.js +79 -0
  183. package/src/ui/loom-model.js +516 -0
  184. package/src/ui/loom.css +349 -0
  185. package/src/ui/loom.js +753 -0
  186. package/src/ui/mark.js +104 -0
  187. package/src/ui/merges.js +158 -0
  188. package/src/ui/rail-model.js +78 -0
  189. package/src/ui/rail.js +173 -0
  190. package/src/ui/releases.js +182 -0
  191. package/src/ui/schedule.js +156 -0
  192. package/src/ui/session.js +3394 -0
  193. package/src/ui/signin.js +75 -0
  194. package/src/ui/sounds.js +110 -0
  195. package/src/ui/tool-logo.js +86 -0
  196. package/src/ui/tree.js +276 -0
  197. package/src/ui/workspaces.js +542 -0
  198. package/src/ui-server.js +1272 -0
  199. package/src/update-check.js +67 -0
  200. package/src/upstream.js +155 -0
  201. package/src/webhooks.js +235 -0
  202. package/src/workspace-cli.js +62 -0
  203. package/src/workspace-tools.js +340 -0
  204. package/index.js +0 -2
@@ -0,0 +1,180 @@
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
+ let lastLineAt = 0
40
+ let stopped = false
41
+ let failed = false
42
+ let first = true
43
+
44
+ const setStatus = (s) => {
45
+ if (s === status) return
46
+ status = s
47
+ onState({ tool: 'Claude Code', status: s })
48
+ }
49
+ setStatus('idle')
50
+
51
+ const inside = (cwd) => typeof cwd === 'string' && (cwd === chatRoot || cwd.startsWith(chatRoot + path.sep))
52
+
53
+ function parseLine (line, file, st) {
54
+ let j
55
+ try { j = JSON.parse(line) } catch { return null }
56
+ // The conversation's title, as the person sees it in Claude Code's list (its own name for the
57
+ // chat, or one they gave it). These lines carry no folder, so they're read before the check.
58
+ // Entries from here on carry it, so the Loom and the feed can name the conversation the same way.
59
+ if (j && (j.type === 'custom-title' || j.type === 'agent-name')) {
60
+ const t = String(j.customTitle || j.agentName || '').replace(/\s+/g, ' ').trim().slice(0, TITLE_CHARS)
61
+ if (t && (j.type === 'custom-title' || !st.title)) st.title = t
62
+ return null
63
+ }
64
+ if (!j || j.isSidechain || !inside(j.cwd)) return null
65
+ const ts = Date.parse(j.timestamp) || now()
66
+ const conv = j.sessionId || path.basename(file, '.jsonl')
67
+ const out = []
68
+ const msg = j.message || {}
69
+
70
+ if (j.type === 'user' && !j.isMeta) {
71
+ const text = typeof msg.content === 'string'
72
+ ? msg.content
73
+ : Array.isArray(msg.content) ? msg.content.filter((b) => b && b.type === 'text').map((b) => b.text).join('\n\n') : ''
74
+ if (text.trim() && !isSystemText(text)) {
75
+ out.push({ id: j.uuid, tool: 'Claude Code', conv, kind: 'prompt', text: capText(text.trim()), ts })
76
+ }
77
+ } else if (j.type === 'assistant' && Array.isArray(msg.content)) {
78
+ msg.content.forEach((b, i) => {
79
+ if (!b) return
80
+ const id = `${j.uuid}:${i}`
81
+ if (b.type === 'text' && b.text && b.text.trim()) {
82
+ out.push({ id, tool: 'Claude Code', conv, kind: 'reply', text: capText(b.text.trim()), ts })
83
+ } else if (b.type === 'tool_use') {
84
+ const target = b.input && (b.input.file_path || b.input.notebook_path)
85
+ const abs = target ? path.resolve(dir, target) : null
86
+ const text = describeAction(b.name, b.input, dir, { existed: (p) => st.seen.has(p) })
87
+ if (abs) st.seen.add(abs)
88
+ out.push({ id, tool: 'Claude Code', conv, kind: 'action', text, ts })
89
+ }
90
+ })
91
+ }
92
+ if (st.title) for (const e of out) e.title = st.title
93
+ return { entries: out, endTurn: j.type === 'assistant' && msg.stop_reason === 'end_turn', relevant: j.type === 'user' || j.type === 'assistant' }
94
+ }
95
+
96
+ function readFile (file, backfill) {
97
+ let st = files.get(file)
98
+ const size = fs.statSync(file).size
99
+ if (!st) {
100
+ st = { offset: 0, partial: '', seen: new Set() }
101
+ files.set(file, st)
102
+ if (!backfill) {
103
+ // Older conversation: only watch it from here on.
104
+ st.offset = size
105
+ return []
106
+ }
107
+ }
108
+ if (size < st.offset) { st.offset = 0; st.partial = '' } // truncated or replaced
109
+ if (size === st.offset) return []
110
+ const fd = fs.openSync(file, 'r')
111
+ let chunk
112
+ try {
113
+ const buf = Buffer.alloc(size - st.offset)
114
+ fs.readSync(fd, buf, 0, buf.length, st.offset)
115
+ chunk = buf.toString('utf8')
116
+ } finally {
117
+ fs.closeSync(fd)
118
+ }
119
+ st.offset = size
120
+ const text = st.partial + chunk
121
+ const lines = text.split('\n')
122
+ st.partial = lines.pop() // incomplete last line (no newline yet)
123
+ const entries = []
124
+ for (const line of lines) {
125
+ if (!line.trim()) continue
126
+ const r = parseLine(line, file, st)
127
+ if (!r) continue
128
+ entries.push(...r.entries)
129
+ if (r.relevant) {
130
+ lastLineAt = now()
131
+ if (!backfill) setStatus(r.endTurn ? 'idle' : 'working')
132
+ }
133
+ }
134
+ return backfill ? entries.slice(-BACKFILL_ENTRIES) : entries
135
+ }
136
+
137
+ function tick () {
138
+ if (stopped) return
139
+ try {
140
+ let dirs = []
141
+ try {
142
+ dirs = fs.readdirSync(root, { withFileTypes: true })
143
+ .filter((d) => d.isDirectory() && d.name.startsWith(slug))
144
+ .map((d) => path.join(root, d.name))
145
+ } catch {} // Claude Code not used yet here
146
+ const out = []
147
+ for (const d of dirs) {
148
+ let names = []
149
+ try { names = fs.readdirSync(d).filter((n) => n.endsWith('.jsonl')) } catch { continue }
150
+ for (const n of names) {
151
+ const file = path.join(d, n)
152
+ const known = files.has(file)
153
+ let backfill = false
154
+ if (!known) {
155
+ // On startup, pick up recent conversations; after that, any new file is live.
156
+ backfill = !first || now() - fs.statSync(file).mtimeMs < BACKFILL_MS
157
+ }
158
+ try { out.push(...readFile(file, backfill)) } catch (err) {
159
+ if (!failed) { failed = true; onLog(`Claude Code feed: could not read ${n}: ${err.message}`) }
160
+ }
161
+ }
162
+ }
163
+ first = false
164
+ if (status === 'working' && now() - lastLineAt > IDLE_AFTER_MS) setStatus('idle')
165
+ if (out.length) onEntries(out.sort((a, b) => a.ts - b.ts))
166
+ } catch (err) {
167
+ if (!failed) { failed = true; onLog(`Claude Code feed error: ${err.message}`) }
168
+ }
169
+ }
170
+
171
+ tick()
172
+ const timer = setInterval(tick, pollMs)
173
+ return { stop () { stopped = true; clearInterval(timer) }, tick }
174
+ }
175
+
176
+ // Slash commands, hook output and similar plumbing that show up as "user" lines.
177
+ function isSystemText (text) {
178
+ 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) ||
179
+ text.startsWith('Caveat: The messages below were generated by the user while running local commands')
180
+ }
@@ -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,170 @@
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
+ let lastLineAt = 0
52
+ let stopped = false
53
+ let failed = false
54
+ let first = true
55
+
56
+ const setStatus = (s) => {
57
+ if (s === status) return
58
+ status = s
59
+ onState({ tool: 'Cursor', status: s })
60
+ }
61
+ setStatus('idle')
62
+
63
+ function parseLine (line, conv, lineNo) {
64
+ let j
65
+ try { j = JSON.parse(line) } catch { return null }
66
+ const ts = now() + lineNo
67
+ const content = j.message && Array.isArray(j.message.content) ? j.message.content : []
68
+ const out = []
69
+ if (j.role === 'user') {
70
+ const text = cursorPromptText(content.filter((b) => b && b.type === 'text' && b.text).map((b) => b.text).join('\n\n'))
71
+ if (text.trim()) out.push({ id: `${conv}:${lineNo}`, tool: 'Cursor', conv, kind: 'prompt', text: capText(text.trim()), ts })
72
+ } else if (j.role === 'assistant') {
73
+ content.forEach((b, i) => {
74
+ if (!b || b.type === 'thinking') return
75
+ const id = `${conv}:${lineNo}:${i}`
76
+ if (b.type === 'text' && b.text && b.text.trim()) out.push({ id, tool: 'Cursor', conv, kind: 'reply', text: capText(b.text.trim()), ts })
77
+ else if (b.type === 'tool_use' && b.name) out.push({ id, tool: 'Cursor', conv, kind: 'action', text: describeAction(b.name, b.input, dir), ts })
78
+ })
79
+ }
80
+ return { entries: out, endTurn: j.type === 'turn_ended', relevant: j.role === 'user' || j.role === 'assistant' || j.type === 'turn_ended' }
81
+ }
82
+
83
+ function readFile (file, backfill) {
84
+ let st = files.get(file)
85
+ const size = fs.statSync(file).size
86
+ if (!st) {
87
+ st = { offset: 0, partial: '', lineNo: 0 }
88
+ files.set(file, st)
89
+ if (!backfill) { st.offset = size; return [] }
90
+ }
91
+ if (size < st.offset) { st.offset = 0; st.partial = ''; st.lineNo = 0 }
92
+ if (size === st.offset) return []
93
+ const fd = fs.openSync(file, 'r')
94
+ let chunk
95
+ try {
96
+ const buf = Buffer.alloc(size - st.offset)
97
+ fs.readSync(fd, buf, 0, buf.length, st.offset)
98
+ chunk = buf.toString('utf8')
99
+ } finally {
100
+ fs.closeSync(fd)
101
+ }
102
+ st.offset = size
103
+ const text = st.partial + chunk
104
+ const lines = text.split('\n')
105
+ st.partial = lines.pop()
106
+ const conv = path.basename(file, '.jsonl')
107
+ const entries = []
108
+ for (const line of lines) {
109
+ if (!line.trim()) continue
110
+ st.lineNo++
111
+ const r = parseLine(line, conv, st.lineNo)
112
+ if (!r) continue
113
+ entries.push(...r.entries)
114
+ if (r.relevant) {
115
+ lastLineAt = now()
116
+ setStatus(r.endTurn ? 'idle' : 'working')
117
+ }
118
+ }
119
+ return backfill ? entries.slice(-BACKFILL_ENTRIES) : entries
120
+ }
121
+
122
+ function transcripts (projectDir) {
123
+ const base = path.join(projectDir, 'agent-transcripts')
124
+ const out = []
125
+ const walk = (d, depth) => {
126
+ if (depth > 3) return
127
+ let entries = []
128
+ try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { return }
129
+ for (const e of entries) {
130
+ const p = path.join(d, e.name)
131
+ if (e.isDirectory()) walk(p, depth + 1)
132
+ else if (e.name.endsWith('.jsonl')) out.push(p)
133
+ }
134
+ }
135
+ walk(base, 0)
136
+ return out
137
+ }
138
+
139
+ function tick () {
140
+ if (stopped) return
141
+ try {
142
+ let dirs = []
143
+ try {
144
+ dirs = fs.readdirSync(root, { withFileTypes: true })
145
+ .filter((d) => d.isDirectory() && projectMatches(d.name, dir))
146
+ .map((d) => path.join(root, d.name))
147
+ } catch {}
148
+ const out = []
149
+ for (const d of dirs) {
150
+ for (const file of transcripts(d)) {
151
+ const known = files.has(file)
152
+ let backfill = false
153
+ if (!known) backfill = !first || now() - fs.statSync(file).mtimeMs < BACKFILL_MS
154
+ try { out.push(...readFile(file, backfill)) } catch (err) {
155
+ if (!failed) { failed = true; onLog(`Cursor feed: could not read ${path.basename(file)}: ${err.message}`) }
156
+ }
157
+ }
158
+ }
159
+ first = false
160
+ if (status === 'working' && now() - lastLineAt > IDLE_AFTER_MS) setStatus('idle')
161
+ if (out.length) onEntries(out)
162
+ } catch (err) {
163
+ if (!failed) { failed = true; onLog(`Cursor feed error: ${err.message}`) }
164
+ }
165
+ }
166
+
167
+ tick()
168
+ const timer = setInterval(tick, pollMs)
169
+ return { stop () { stopped = true; clearInterval(timer) } }
170
+ }