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,516 @@
1
+ // The Loom: every AI conversation in the session at once. One lane per person
2
+ // (their thread), time running down like a feed. Each AI turn (a prompt, what
3
+ // the AI did, its reply) is one card in its person's lane, and so are chat
4
+ // messages and task notes. Cards in different lanes that touched the same file
5
+ // are stitched together. When the lanes don't all fit, the quiet ones fold into
6
+ // thin strips (and past a few strips, into one "crowd" column) where each thing
7
+ // they did is a knot in the same rows. Pure, so it can be tested without a browser.
8
+ import { conversations } from './feed-convs.js'
9
+ import { aiName } from './chat.js'
10
+
11
+ export const GAP_MS = 20 * 60 * 1000 // a pause this long between two things gets a "later" divider
12
+ export const TURN_GAP_MS = 10 * 60 * 1000 // a conversation quiet this long starts a new turn without a prompt
13
+ export const COLLIDE_MS = 15 * 60 * 1000 // two lanes on one file this close together: shown as a collision
14
+ export const MAX_ITEMS = 400 // newest first; older ones drop off the top
15
+ export const CONV_SLOTS = 8 // a lane's conversations each get a piece of the mark: eight, the corner last
16
+ export const CONV_QUIET_MS = 30 * 60 * 1000 // a conversation with nothing for this long shows as quiet
17
+ export const CONV_ACTIVE_MS = 3 * 60 * 1000 // one with something this recent is working now: its piece runs laps
18
+ const MAX_STITCHES = 240
19
+ const PREVIEW = 600
20
+
21
+ // Column widths in px, for deciding how many lanes fit (loom.css draws them).
22
+ export const LANE_MIN = 250
23
+ export const STRIP_W = 46
24
+ export const CROWD_W = 58
25
+ const COL_GAP = 18
26
+ const PAD_X = 36
27
+ export const MAX_STRIPS = 6 // more folded lanes than this: the quietest share the crowd column
28
+ const RECENT_OPENS = 2 // the lanes you opened last stay open ahead of working AIs
29
+ export const MIN_STRIPS = 4 // a lane opens only while this many folded ones still show as strips
30
+
31
+ /**
32
+ * Which lanes stay open when they don't all fit in `width`: a Map name ->
33
+ * 'open' | 'strip' | 'crowd'. As many open as fit, at least one. They're picked
34
+ * in this order: pinned, the last two lanes you opened (`opened`, latest first),
35
+ * working AIs, you, lanes opened before that, then whoever did something most recently. Of the folded
36
+ * ones, the first few are strips and the quietest share the crowd column; another
37
+ * lane opens only while at least MIN_STRIPS of the folded ones still get a strip.
38
+ */
39
+ export function foldPlan (lanes, { width = 0, pinned = [], opened = [] } = {}) {
40
+ const plan = new Map(lanes.map((l) => [l.name, 'open']))
41
+ const n = lanes.length
42
+ const need = (k, s, c) => k * LANE_MIN + s * STRIP_W + (c ? CROWD_W : 0) + Math.max(0, k + s + (c ? 1 : 0) - 1) * COL_GAP + PAD_X
43
+ if (!width || n <= 1 || need(n, 0, 0) <= width) return plan
44
+
45
+ const pin = (l) => { const i = pinned.indexOf(l.name); return i === -1 ? Infinity : i }
46
+ const seen = (l) => { const i = opened.indexOf(l.name); return i === -1 ? Infinity : i }
47
+ const tiers = [
48
+ (l) => pin(l),
49
+ (l) => Math.min(seen(l), RECENT_OPENS),
50
+ (l) => (l.working ? 0 : 1),
51
+ (l) => (l.isMe ? 0 : 1),
52
+ (l) => seen(l),
53
+ (l) => -(l.lastTs || 0)
54
+ ]
55
+ const at = new Map(lanes.map((l, i) => [l.name, i]))
56
+ const order = [...lanes].sort((a, b) => {
57
+ for (const t of tiers) {
58
+ const x = t(a)
59
+ const y = t(b)
60
+ if (x !== y) return x < y ? -1 : 1
61
+ }
62
+ return at.get(a.name) - at.get(b.name)
63
+ })
64
+
65
+ let k = 1
66
+ let s = 0
67
+ for (let open = n - 1; open >= 1; open--) {
68
+ const rest = n - open
69
+ let strips = Math.min(rest, MAX_STRIPS)
70
+ let crowd = rest - strips
71
+ if (crowd === 1) { strips++; crowd = 0 } // a crowd of one is just a strip
72
+ while (strips >= 0 && need(open, strips, crowd) > width) { strips--; crowd++ }
73
+ if (strips >= Math.min(rest, MIN_STRIPS) || open === 1) { k = open; s = Math.max(0, strips); break }
74
+ }
75
+ order.forEach((l, i) => plan.set(l.name, i < k ? 'open' : i < k + s ? 'strip' : 'crowd'))
76
+ return plan
77
+ }
78
+
79
+ /**
80
+ * What an action line says about a file: { verb, path }, or null when it names
81
+ * none. Action lines come from every tool's reader in one form ("Edited a/b.js",
82
+ * "Read a/b.js", see src/agents/actions.js).
83
+ */
84
+ export function fileOf (text) {
85
+ const m = String(text || '').match(/^(Edited|Created|Deleted|Read) (.+)$/)
86
+ if (!m || m[2] === 'a file') return null
87
+ return { verb: m[1].toLowerCase(), path: m[2].trim() }
88
+ }
89
+
90
+ const changes = (verb) => verb === 'edited' || verb === 'created' || verb === 'deleted'
91
+
92
+ /**
93
+ * Each lane's AI conversations: a person can have several going at once (windows, tools), and
94
+ * all of them land in their one lane. Each gets a slot of the mark (0-7, clockwise from the top
95
+ * left, the corner last) as it starts, so a card can show which conversation it's from. Slots are
96
+ * handed out over what's in view (`items`), so one frees up when its conversation scrolls away;
97
+ * with all eight taken, the conversation quiet longest gives its slot up. Named by the person's
98
+ * own name for it (`convNames`), else the tool's title or first prompt (turnsOf's convLabel).
99
+ * Sets each lane's `convs` ([{ conv, slot, name, tool, live, active, turns, last }] in slot order, those
100
+ * holding a slot), its `tool` (the latest conversation's, when one says), each turn's `convLabel` and,
101
+ * in a lane with more than one, its `convSlot`.
102
+ */
103
+ function conversationsOf (lanes, items, { convNames = {}, now = Date.now() } = {}) {
104
+ const byLane = new Map()
105
+ for (const it of items) {
106
+ if (it.type !== 'turn') continue
107
+ if (!byLane.has(it.lane)) byLane.set(it.lane, new Map())
108
+ const convs = byLane.get(it.lane)
109
+ let c = convs.get(it.conv)
110
+ if (!c) { c = { conv: it.conv, tool: it.tool, label: it.convLabel, first: it.ts, last: it.end || it.ts, turns: 0 }; convs.set(it.conv, c) }
111
+ c.last = Math.max(c.last, it.end || it.ts)
112
+ if (!c.tool && it.tool) c.tool = it.tool
113
+ c.turns++
114
+ }
115
+ for (const l of lanes) {
116
+ const list = [...(byLane.get(l.name) || new Map()).values()].sort((a, b) => a.first - b.first)
117
+ const slots = new Array(CONV_SLOTS).fill(null)
118
+ for (const c of list) {
119
+ let at = slots.indexOf(null)
120
+ if (at < 0) at = slots.reduce((best, x, i) => (x.last < slots[best].last ? i : best), 0)
121
+ slots[at] = c
122
+ c.slot = at
123
+ }
124
+ const own = convNames && typeof convNames === 'object' ? convNames : {}
125
+ for (const c of list) c.name = (typeof own[c.conv] === 'string' && own[c.conv].trim()) || c.label || c.tool || 'Conversation'
126
+ l.convs = slots.filter(Boolean).map((c) => ({ conv: c.conv, slot: c.slot, name: c.name, tool: c.tool || null, live: now - c.last < CONV_QUIET_MS, active: now - c.last < CONV_ACTIVE_MS, turns: c.turns, last: c.last }))
127
+ // The lane's mark is the tool its latest conversation came from, over the one its session started with.
128
+ const latest = list.filter((c) => c.tool && c.tool !== 'unknown').reduce((a, c) => (!a || c.last >= a.last ? c : a), null)
129
+ if (latest) l.tool = latest.tool
130
+ const of = new Map(list.map((c) => [c.conv, c]))
131
+ for (const it of items) {
132
+ if (it.type !== 'turn' || it.lane !== l.name) continue
133
+ const c = of.get(it.conv)
134
+ it.convLabel = c.name
135
+ if (list.length > 1) it.convSlot = c.slot
136
+ }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * One person's feed as turns, oldest first. A turn starts at a prompt (or at the
142
+ * first thing in a conversation, or after it went quiet) and gathers that
143
+ * conversation's actions and replies until its next prompt. Concurrent
144
+ * conversations of one person each keep their own turn open. Pause and resume
145
+ * marks come through as their own items.
146
+ */
147
+ export function turnsOf (entries, lane) {
148
+ const list = (Array.isArray(entries) ? entries : []).filter((e) => e && typeof e === 'object')
149
+ const labels = new Map(conversations(list).map((c) => [c.conv, c.label]))
150
+ const out = []
151
+ const open = new Map() // conv -> its turn
152
+ for (const e of [...list].sort((a, b) => (a.ts || 0) - (b.ts || 0))) {
153
+ const ts = Number(e.ts) || 0
154
+ if (e.kind === 'paused' || e.kind === 'resumed') {
155
+ out.push({ type: e.kind, lane, ts, key: `${lane}|${e.id}` })
156
+ continue
157
+ }
158
+ if (!['prompt', 'reply', 'action'].includes(e.kind)) continue
159
+ const conv = String(e.conv || '')
160
+ let t = open.get(conv)
161
+ if (e.kind === 'prompt' || !t || ts - t.end > TURN_GAP_MS) {
162
+ t = {
163
+ type: 'turn',
164
+ key: `${lane}|${e.id}`,
165
+ lane,
166
+ conv,
167
+ convLabel: labels.get(conv) || '',
168
+ tool: e.tool || null,
169
+ ts,
170
+ end: ts,
171
+ prompt: '',
172
+ summary: false,
173
+ replies: [],
174
+ actions: [],
175
+ edits: [],
176
+ reads: 0,
177
+ runs: 0
178
+ }
179
+ out.push(t)
180
+ open.set(conv, t)
181
+ }
182
+ if (!t.tool && e.tool) t.tool = e.tool
183
+ t.end = Math.max(t.end, ts)
184
+ const text = String(e.text || '')
185
+ if (e.kind === 'prompt') {
186
+ t.prompt = text
187
+ if (e.summary) t.summary = true
188
+ } else if (e.kind === 'reply') {
189
+ t.replies.push({ text, ts, summary: !!e.summary })
190
+ } else {
191
+ t.actions.push(text)
192
+ const f = fileOf(text)
193
+ if (f && changes(f.verb)) { if (!t.edits.includes(f.path)) t.edits.push(f.path) } else if (f) t.reads++
194
+ else if (/^Ran /.test(text)) t.runs++
195
+ }
196
+ }
197
+ return out
198
+ }
199
+
200
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
201
+
202
+ /**
203
+ * The members `text` @mentions, by the rule chat uses (src/ui/chat.js): the @
204
+ * starts a word and the name ends one. @Agents names every agent in `agents`.
205
+ */
206
+ export function mentionsIn (text, names, agents = []) {
207
+ const t = String(text || '')
208
+ if (!t.includes('@')) return []
209
+ const list = [...new Set([...names, 'Agents'].filter(Boolean))].sort((a, b) => b.length - a.length)
210
+ const re = new RegExp(`(^|[^\\w@])@(${list.map(escapeRe).join('|')})(?![\\w-])`, 'giu')
211
+ const out = new Set()
212
+ for (const m of t.matchAll(re)) {
213
+ const said = m[2].toLowerCase()
214
+ if (said === 'agents') { for (const a of agents) out.add(a); continue }
215
+ const name = names.find((n) => n.toLowerCase() === said)
216
+ if (name) out.add(name)
217
+ }
218
+ return [...out]
219
+ }
220
+
221
+ /**
222
+ * Rows for the cards, in time order: a card starts no earlier than the one
223
+ * before it (anywhere), and below the last card in its own lane, so cards in
224
+ * different lanes sit side by side while order reads top to bottom. A card spans
225
+ * down to the next card in its lane, so a long turn doesn't push its neighbours
226
+ * apart. Dividers take a row across every lane. Rows count from 1 (CSS grid).
227
+ */
228
+ export function packRows (items, laneCount) {
229
+ const next = new Array(Math.max(1, laneCount)).fill(1)
230
+ const last = new Array(Math.max(1, laneCount)).fill(null)
231
+ let floor = 1
232
+ for (const it of items) {
233
+ if (it.col == null) {
234
+ const row = Math.max(floor, ...next)
235
+ it.row = row
236
+ it.span = 1
237
+ for (let i = 0; i < next.length; i++) {
238
+ if (last[i]) last[i].span = Math.max(1, row - last[i].row)
239
+ next[i] = row + 1
240
+ last[i] = null
241
+ }
242
+ floor = row + 1
243
+ continue
244
+ }
245
+ const row = Math.max(floor, next[it.col])
246
+ const prev = last[it.col]
247
+ if (prev) prev.span = Math.max(1, row - prev.row)
248
+ it.row = row
249
+ it.span = 1
250
+ next[it.col] = row + 1
251
+ last[it.col] = it
252
+ floor = row
253
+ }
254
+ return Math.max(floor, ...next) - 1
255
+ }
256
+
257
+ /** How long someone has been stitching since `since`: "<1m", "25m", "1h 10m", "6h", "2d". */
258
+ export function stitchTime (since, now = Date.now()) {
259
+ const m = Math.floor(Math.max(0, now - since) / 60000)
260
+ if (m < 1) return '<1m'
261
+ if (m < 60) return `${m}m`
262
+ const h = Math.floor(m / 60)
263
+ if (h >= 48) return `${Math.floor(h / 24)}d`
264
+ return h < 6 && m % 60 ? `${h}h ${m % 60}m` : `${h}h`
265
+ }
266
+
267
+ function dayOf (ts) {
268
+ const d = new Date(ts)
269
+ return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
270
+ }
271
+
272
+ /**
273
+ * One lane per person's AI, as chat shows it: every AI session of a person ("Daniel · Claude
274
+ * Code", "Daniel · Claude Code 2", ones gone since) becomes "Daniel's AI". `owners`: AI session
275
+ * name -> its person (chat.js aiOwners). Returns the buildLoom inputs with those names folded,
276
+ * and `personOf`: lane name -> the person whose AI chat it opens.
277
+ */
278
+ export function foldAiLanes ({ people = [], owners = new Map(), feeds = new Map(), messages = [], tasks = [], claims = [] } = {}) {
279
+ const lane = (name) => owners.has(name) ? aiName(owners.get(name)) : name
280
+ const personOf = new Map()
281
+ const out = []
282
+ const groups = new Map()
283
+ for (const p of people) {
284
+ if (!p || !p.name) continue
285
+ if (!owners.has(p.name)) { out.push(p); continue }
286
+ const name = lane(p.name)
287
+ const g = groups.get(name)
288
+ if (g) {
289
+ g.online = g.online || p.online
290
+ if (!g.tool && p.tool) g.tool = p.tool
291
+ // Several sessions stitching: the lane's clock is the one that has run longest.
292
+ if (p.focusAt > 0 && !(g.focusAt <= p.focusAt)) { g.focusAt = p.focusAt; g.focus = p.focus || '' }
293
+ continue
294
+ }
295
+ const folded = { name, kind: 'agent', tool: p.tool || '', online: p.online || false, optional: true, agent: null, ...(p.focusAt > 0 ? { focusAt: p.focusAt, focus: p.focus || '' } : {}) }
296
+ groups.set(name, folded)
297
+ personOf.set(name, owners.get(p.name))
298
+ out.push(folded)
299
+ }
300
+ // An AI session that is gone still has its chat and notes: they go to its person's AI lane.
301
+ for (const person of new Set(owners.values())) {
302
+ const name = aiName(person)
303
+ if (groups.has(name)) continue
304
+ groups.set(name, true)
305
+ personOf.set(name, person)
306
+ out.push({ name, kind: 'agent', tool: '', online: false, optional: true, agent: null })
307
+ }
308
+ const folded = new Map()
309
+ for (const [name, list] of feeds) {
310
+ const key = lane(name)
311
+ const items = Array.isArray(list) ? list : []
312
+ folded.set(key, folded.has(key) ? [...folded.get(key), ...items].sort((a, b) => (a.ts || 0) - (b.ts || 0)) : items)
313
+ }
314
+ const by = (x) => owners.has(x.by) ? { ...x, by: lane(x.by) } : x
315
+ return {
316
+ people: out,
317
+ feeds: folded,
318
+ messages: (messages || []).map((m) => m && (owners.has(m.by) || owners.has(m.to)) ? { ...m, by: lane(m.by), ...(m.to ? { to: lane(m.to) } : {}) } : m),
319
+ tasks: (tasks || []).map((t) => t ? { ...by(t), comments: (t.comments || []).map(by) } : t),
320
+ claims: (claims || []).map((c) => c ? by(c) : c),
321
+ personOf
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Everything the Loom shows. `people`: [{ name, color, isMe, kind, tool, online,
327
+ * agent, optional, focus, focusAt }] in lane order; `feeds`: Map name -> feed entries; `messages`: the
328
+ * room's chat (already validated); `tasks`: the board, with comments; `claims`:
329
+ * the session's claims. `show`: { chat, tasks } (both on unless false);
330
+ * `hidden`: names whose lanes are off; `merged`: one column for everyone (a
331
+ * narrow window). `width`: the room for lanes, in px (0: as many as there are);
332
+ * `pinned`, `opened`: see foldPlan; `convNames`: { conv id: name } the person gave conversations
333
+ * (on this computer); `now` for what counts as quiet. Returns { lanes, columns, items, knots, rows,
334
+ * stitches, mentions, files }: a lane's `fold` is 'open', 'strip' or 'crowd' and
335
+ * `col` its column; things in a folded lane aren't cards but `knots`, one per
336
+ * column and row.
337
+ */
338
+ export function buildLoom ({ people = [], feeds = new Map(), messages = [], tasks = [], claims = [], show = {}, hidden = [], merged = false, width = 0, pinned = [], opened = [], convNames = {}, now = Date.now() } = {}) {
339
+ const off = new Set(hidden)
340
+ // Someone `optional` (an AI session working through a person's app) gets a lane only with something in it.
341
+ const active = new Set([...feeds].filter(([, list]) => Array.isArray(list) && list.length).map(([name]) => name))
342
+ for (const m of messages || []) active.add(m.by)
343
+ for (const t of tasks || []) { active.add(t.by); for (const c of t.comments || []) active.add(c.by) }
344
+ const lanes = people.filter((p) => p && p.name && !off.has(p.name) && (!p.optional || active.has(p.name))).map((p) => {
345
+ const agent = p.agent || null
346
+ const sharing = !agent || agent.sharing !== false
347
+ return {
348
+ name: p.name,
349
+ color: p.color || null,
350
+ isMe: !!p.isMe,
351
+ kind: p.kind === 'agent' ? 'agent' : 'human',
352
+ tool: p.tool && p.tool !== 'unknown' ? p.tool : (agent && agent.tool) || null,
353
+ online: p.online || false,
354
+ sharing,
355
+ working: sharing && !!agent && agent.status === 'working',
356
+ // Since when they've been on their focus (stitching); hidden like their status while paused.
357
+ since: sharing && p.focusAt > 0 ? p.focusAt : 0,
358
+ focus: sharing && p.focusAt > 0 ? String(p.focus || '') : '',
359
+ pinned: pinned.includes(p.name),
360
+ holds: [],
361
+ waiting: 0,
362
+ lastTs: 0,
363
+ fold: 'open',
364
+ col: 0
365
+ }
366
+ })
367
+ const index = new Map(lanes.map((l, i) => [l.name, i]))
368
+ const names = people.map((p) => p && p.name).filter(Boolean)
369
+ const agentNames = people.filter((p) => p && p.kind === 'agent').map((p) => p.name)
370
+
371
+ for (const c of claims || []) {
372
+ const i = index.get(c && c.by)
373
+ if (i === undefined) continue
374
+ lanes[i].holds.push(c.pattern)
375
+ lanes[i].waiting += Array.isArray(c.queue) ? c.queue.length : 0
376
+ }
377
+
378
+ let items = []
379
+ for (const l of lanes) items.push(...turnsOf(feeds.get(l.name), l.name))
380
+
381
+ const doneByConv = new Map() // conv -> tasks filed under it
382
+ for (const t of tasks || []) {
383
+ if (!t || t.archived) continue
384
+ if (t.conv) {
385
+ if (!doneByConv.has(t.conv)) doneByConv.set(t.conv, [])
386
+ doneByConv.get(t.conv).push({ id: t.id, title: t.title, column: t.column })
387
+ }
388
+ if (show.tasks === false) continue
389
+ if (index.has(t.by)) items.push({ type: 'task', key: `task|${t.id}`, lane: t.by, ts: Number(t.ts) || 0, task: { id: t.id, title: t.title, column: t.column, assignee: t.assignee || '' } })
390
+ for (const c of t.comments || []) {
391
+ if (index.has(c.by)) items.push({ type: 'note', key: `note|${c.id}`, lane: c.by, ts: Number(c.ts) || 0, text: String(c.text || '').slice(0, PREVIEW), task: { id: t.id, title: t.title, column: t.column } })
392
+ }
393
+ }
394
+ for (const it of items) if (it.type === 'turn' && it.conv && doneByConv.has(it.conv)) it.tasks = doneByConv.get(it.conv)
395
+
396
+ if (show.chat !== false) {
397
+ for (const m of messages || []) {
398
+ if (!index.has(m.by)) continue
399
+ const text = String(m.text || '')
400
+ items.push({
401
+ type: 'chat',
402
+ key: `chat|${m.id}`,
403
+ lane: m.by,
404
+ ts: Number(m.ts) || 0,
405
+ text: text.slice(0, PREVIEW),
406
+ to: m.to || '',
407
+ file: m.file ? String(m.file.name || '') : '',
408
+ mentions: [...new Set([...(m.to ? [m.to] : []), ...mentionsIn(text, names, agentNames)])].filter((n) => n !== m.by)
409
+ })
410
+ }
411
+ }
412
+
413
+ items.sort((a, b) => a.ts - b.ts)
414
+ if (items.length > MAX_ITEMS) items = items.slice(-MAX_ITEMS)
415
+ for (const it of items) { const l = lanes[index.get(it.lane)]; if (l && it.ts > l.lastTs) l.lastTs = it.ts }
416
+ conversationsOf(lanes, items, { convNames, now })
417
+
418
+ // Columns: open lanes and strips in lane order, then the crowd (if any) last.
419
+ const plan = merged ? new Map(lanes.map((l) => [l.name, 'open'])) : foldPlan(lanes, { width, pinned, opened })
420
+ const columns = []
421
+ const crowd = []
422
+ for (const l of lanes) {
423
+ l.fold = plan.get(l.name)
424
+ if (merged) { l.col = 0; continue }
425
+ if (l.fold === 'crowd') { crowd.push(l); continue }
426
+ l.col = columns.length
427
+ columns.push({ kind: l.fold, lane: l.name })
428
+ }
429
+ if (merged) columns.push({ kind: 'merged' })
430
+ if (crowd.length) {
431
+ for (const l of crowd) l.col = columns.length
432
+ columns.push({ kind: 'crowd', lanes: crowd.map((l) => l.name) })
433
+ }
434
+ const laneOf = (name) => lanes[index.get(name)]
435
+
436
+ // "Later" and day dividers between things far apart.
437
+ const withGaps = []
438
+ let prevTs = null
439
+ for (const it of items) {
440
+ if (prevTs !== null && it.ts) {
441
+ if (dayOf(it.ts) !== dayOf(prevTs)) withGaps.push({ type: 'day', key: `day|${it.ts}`, ts: it.ts })
442
+ else if (it.ts - prevTs >= GAP_MS) withGaps.push({ type: 'gap', key: `gap|${it.ts}`, ts: it.ts, ms: it.ts - prevTs })
443
+ }
444
+ withGaps.push(it)
445
+ if (it.ts) prevTs = it.ts
446
+ }
447
+ items = withGaps
448
+ items.forEach((it, i) => {
449
+ it.id = `k${i}`
450
+ const l = it.lane == null ? null : laneOf(it.lane)
451
+ it.col = l ? l.col : null
452
+ it.folded = !!l && l.fold !== 'open'
453
+ })
454
+ let rows = packRows(items.filter((it) => !it.folded), columns.length)
455
+
456
+ // A folded lane's things sit in the row of the card just before them (or just after,
457
+ // at the top or after a divider), one knot per column and row.
458
+ let cur = null
459
+ let lastRow = null
460
+ let pending = []
461
+ for (const it of items) {
462
+ if (it.folded) { if (cur !== null) it.row = cur; else pending.push(it); continue }
463
+ if (it.col == null) { cur = null; continue }
464
+ cur = lastRow = it.row
465
+ for (const p of pending) p.row = cur
466
+ pending = []
467
+ }
468
+ if (pending.length) {
469
+ if (lastRow === null || items.some((it) => !it.folded && it.col == null && it.row > lastRow)) lastRow = ++rows
470
+ for (const p of pending) p.row = lastRow
471
+ }
472
+ const knots = []
473
+ const knotAt = new Map()
474
+ for (const it of items) {
475
+ if (!it.folded) continue
476
+ const at = `${it.col}|${it.row}`
477
+ let k = knotAt.get(at)
478
+ if (!k) { k = { id: `n${knots.length}`, col: it.col, row: it.row, ids: [], keys: [], lanes: [], ts: 0 }; knotAt.set(at, k); knots.push(k) }
479
+ k.ids.push(it.id)
480
+ k.keys.push(it.key)
481
+ if (!k.lanes.includes(it.lane)) k.lanes.push(it.lane)
482
+ k.ts = Math.max(k.ts, it.ts)
483
+ }
484
+
485
+ // Files: who touched each, in order, and the stitches between lanes.
486
+ const files = new Map() // path -> { touches: [{ id, lane, ts }], claim }
487
+ for (const it of items) {
488
+ if (it.type !== 'turn') continue
489
+ for (const p of it.edits) {
490
+ if (!files.has(p)) files.set(p, { touches: [], claim: null })
491
+ files.get(p).touches.push({ id: it.id, lane: it.lane, ts: it.ts, end: it.end })
492
+ }
493
+ }
494
+ for (const c of claims || []) {
495
+ if (c && files.has(c.pattern)) files.get(c.pattern).claim = { by: c.by, waiting: Array.isArray(c.queue) ? c.queue.map((r) => r.by) : [] }
496
+ }
497
+ const stitches = []
498
+ for (const [path, f] of files) {
499
+ for (let i = 1; i < f.touches.length; i++) {
500
+ const a = f.touches[i - 1]
501
+ const b = f.touches[i]
502
+ if (a.lane === b.lane) continue
503
+ stitches.push({ from: a.id, to: b.id, path, collide: b.ts - a.end <= COLLIDE_MS })
504
+ }
505
+ }
506
+ stitches.sort((x, y) => Number(y.collide) - Number(x.collide))
507
+ if (stitches.length > MAX_STITCHES) stitches.length = MAX_STITCHES
508
+
509
+ const mentions = []
510
+ for (const it of items) {
511
+ if (it.type !== 'chat') continue
512
+ for (const n of it.mentions) if (index.has(n)) mentions.push({ from: it.id, lane: n, col: laneOf(n).col })
513
+ }
514
+
515
+ return { lanes, columns, items, knots, rows, stitches, mentions, files }
516
+ }