heyquilt 0.3.90 → 0.3.107

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 (84) hide show
  1. package/README.md +14 -11
  2. package/RELEASES.md +135 -2
  3. package/bin/quilt.js +45 -12
  4. package/package.json +3 -3
  5. package/src/account.js +28 -0
  6. package/src/agents/claude-code.js +11 -4
  7. package/src/agents/cursor-transcripts.js +11 -4
  8. package/src/ai-vm-signin.js +14 -2
  9. package/src/ai-vm.js +150 -8
  10. package/src/api/agent-auth.js +9 -3
  11. package/src/api/fly-machines.js +72 -15
  12. package/src/api/memory-store.js +142 -6
  13. package/src/api/relay-cluster.js +337 -43
  14. package/src/api/relay-machines.js +35 -3
  15. package/src/api/releases.js +87 -0
  16. package/src/api/routes/agents.js +111 -16
  17. package/src/api/routes/ai-vm.js +146 -19
  18. package/src/api/routes/orgs.js +3 -1
  19. package/src/api/routes/secrets.js +448 -0
  20. package/src/api/routes/workspaces.js +8 -1
  21. package/src/api/sandbox-keys.js +57 -0
  22. package/src/api/secrets-access.js +93 -0
  23. package/src/api/server.js +40 -9
  24. package/src/api/supabase-store.js +121 -11
  25. package/src/autowork.js +3 -15
  26. package/src/catchup.js +1 -1
  27. package/src/clash.js +10 -22
  28. package/src/control.js +18 -15
  29. package/src/fsutil.js +11 -1
  30. package/src/gitstate.js +29 -5
  31. package/src/hooks.js +1 -1
  32. package/src/mcp-proxy.js +7 -2
  33. package/src/mcp.js +22 -25
  34. package/src/merge3.js +94 -12
  35. package/src/pass-source.js +9 -2
  36. package/src/pathrules.js +7 -0
  37. package/src/relay-clash.js +101 -484
  38. package/src/relay-githttp.js +36 -7
  39. package/src/relay-mcp.js +19 -7
  40. package/src/relay-metrics.js +66 -1
  41. package/src/relay-outbound.js +154 -25
  42. package/src/relay-sync.js +133 -136
  43. package/src/relay-turn-sync.js +250 -51
  44. package/src/relay-upstream.js +49 -179
  45. package/src/releases.js +16 -9
  46. package/src/runner.js +3 -2
  47. package/src/secret-import.js +55 -0
  48. package/src/secret-names.js +26 -0
  49. package/src/secret-run.js +80 -0
  50. package/src/secrets-client.js +56 -0
  51. package/src/server.js +71 -44
  52. package/src/session.js +347 -262
  53. package/src/status.js +3 -5
  54. package/src/sync-words.js +1 -1
  55. package/src/turn-sync-plan.js +13 -10
  56. package/src/turn-sync.js +32 -11
  57. package/src/ui/agent-guide.js +4 -4
  58. package/src/ui/app.css +208 -23
  59. package/src/ui/app.js +4 -0
  60. package/src/ui/cloud-ai-model.js +3 -2
  61. package/src/ui/common.js +2 -0
  62. package/src/ui/fileview.js +28 -1
  63. package/src/ui/home.js +32 -33
  64. package/src/ui/loom.css +18 -2
  65. package/src/ui/loom.js +41 -8
  66. package/src/ui/rail-model.js +6 -9
  67. package/src/ui/releases.js +1 -1
  68. package/src/ui/sandbox-keys-model.js +53 -0
  69. package/src/ui/sandbox-keys.js +132 -0
  70. package/src/ui/secret-rules.js +255 -0
  71. package/src/ui/secrets-view.js +162 -0
  72. package/src/ui/secrets.js +283 -0
  73. package/src/ui/session.js +47 -149
  74. package/src/ui/tree.js +23 -18
  75. package/src/ui/workspaces.js +5 -0
  76. package/src/ui-server.js +140 -28
  77. package/src/update-check.js +1 -1
  78. package/src/upstream.js +75 -38
  79. package/src/vault/crypto.js +96 -0
  80. package/src/vault/kms.js +61 -0
  81. package/src/vault/main.js +33 -0
  82. package/src/vault/server.js +279 -0
  83. package/src/vault/sigv4.js +38 -0
  84. package/src/ui/merges.js +0 -158
package/src/mcp-proxy.js CHANGED
@@ -6,9 +6,11 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
7
7
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
8
8
  import { hostedClient } from './hosted-client.js'
9
+ import { secretNames } from './secrets-client.js'
10
+ import { describeSecretNames, SECRET_NAMES_DESCRIPTION, RUN_GUIDE } from './secret-names.js'
9
11
 
10
12
  const NOTE = (room) => `\n\nYou reach Quilt with this sandbox's QUILT_AGENT_KEY: every tool is about session ${room}, filled in for you. ` +
11
- 'Your folder is synced by `npx heyquilt sync`: run it at the start of every turn (it brings in what changed and says what waits for you) and again at the end (it sends what you changed).'
13
+ 'Your folder is synced by `npx heyquilt sync`: run it at the start of every turn (it brings in what changed and says what waits for you) and again at the end (it sends what you changed). ' + RUN_GUIDE
12
14
 
13
15
  /** The tools as the hosted MCP lists them, without `session` (the proxy fills it in). */
14
16
  export function withoutSession (tools) {
@@ -27,9 +29,12 @@ export async function runMcpProxy ({ key, client = hostedClient({ key }), transp
27
29
  instructions = (init && init.instructions) || ''
28
30
  } catch {} // the first tool call says what is wrong (a revoked key, no network)
29
31
  const server = new Server({ name: 'quilt', version: '0.2.0' }, { capabilities: { tools: {} }, instructions: instructions + NOTE(key.room) })
30
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: withoutSession((await client.rpc('tools/list', {})).tools) }))
32
+ // quilt_secret_names is answered here, as this agent with its key: a sandbox runs commands
33
+ // (through `quilt run`), unlike the hosted agents the relay's tool speaks to.
34
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: withoutSession((await client.rpc('tools/list', {})).tools).map((t) => (t.name === 'quilt_secret_names' ? { ...t, description: SECRET_NAMES_DESCRIPTION } : t)) }))
31
35
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
32
36
  try {
37
+ if (req.params.name === 'quilt_secret_names') return { content: [{ type: 'text', text: describeSecretNames(await secretNames({ api: key.api, token: key.key }, key.room)) }] }
33
38
  return await client.callRaw(req.params.name, req.params.arguments || {})
34
39
  } catch (e) {
35
40
  return { content: [{ type: 'text', text: e.message }], isError: true }
package/src/mcp.js CHANGED
@@ -39,6 +39,7 @@ import { UpdateCheck } from './update-check.js'
39
39
  import { getSettings } from './settings.js'
40
40
  import { mergeAction } from './merges.js'
41
41
  import { CLASH_DESCRIPTION, clashSchema } from './relay-clash.js'
42
+ import { describeSecretNames, SECRET_NAMES_DESCRIPTION, RUN_GUIDE } from './secret-names.js'
42
43
 
43
44
  // The board's columns, from tasks.js, so this and the hosted MCP (relay-mcp.js) never drift apart.
44
45
  const COLUMN_IDS_LIST = COLUMNS.map((c) => c.id)
@@ -94,12 +95,11 @@ export const MCP_INSTRUCTIONS =
94
95
  'When you finish a piece of work, call quilt_set_work with "done": it lets go of the files claimed for you. ' +
95
96
  'Claim ahead (quilt_claim) only for a larger change across several files. ' +
96
97
  'Always re-read a file right before you edit it. ' +
97
- 'If quilt_status lists merges to settle, read quilt_merges before editing those files. ' +
98
98
  'If quilt_status starts with While you were away, the session changed since this folder last synced: read quilt_history for those files before editing them. ' +
99
99
  'Mentions of you (@yourname) in chat, direct messages to you and tasks handed to you wait in quilt_inbox: read it when you start, and act on each one. ' +
100
100
  'Each comes with the conversation before it with its sender; when a message refers to something earlier you do not have, read back with quilt_conversation before you answer, never guess. ' +
101
101
  'Git works here as usual: commit only the files you changed and push yourself. When the relay serves the session\'s git, your folder\'s origin is the relay: git signs in with your Quilt pass, and the relay checks each push (the session owner\'s policy, never a force-push, no ignored files) and puts it on GitHub, replayed onto GitHub\'s head if it moved (your folder follows). Otherwise your folder signs in to GitHub through Quilt (a short-lived token for this repository). Either way pushes stay within the owner\'s policy: by default branches of your own. Taking a task (quilt_move_task to doing) puts your folder on quilt/<you>/<task>; push there and Quilt opens the pull request for the task; move the task to QA when it is done and tested; Done merges the pull request once its checks pass (a person moves it to Done unless the owner lets agents push to any branch). When agents may not commit, ask a person with quilt_request_commit. ' +
102
- 'When a branch syncs with GitHub (quilt_branches says so), pushes and merges on GitHub come into the session by themselves; nothing goes to GitHub until someone commits and pushes. A file GitHub and the session both changed is held, and its merger proposes the merge with quilt_clash (the reviewer accepts; writing it directly is undone). Releasing a file only lets go of your claim: it publishes nothing by itself. ' +
102
+ 'When a branch syncs with GitHub (quilt_branches says so), pushes and merges on GitHub come into the session by themselves; nothing goes to GitHub until someone commits and pushes. A file GitHub and the session both changed is merged by Quilt on its own (the newer edit stays where both changed the same lines). Releasing a file only lets go of your claim: it publishes nothing by itself. ' +
103
103
  'Work only in the session has not shipped: push before you move a task to QA. Pushes and merges on GitHub reach the session within seconds: never pull by hand. ' +
104
104
  'When you are about to answer someone in chat, quilt_typing shows them you are typing; sending clears it. ' +
105
105
  'Chat: ' + CHAT_RULES.replace(/^Send a chat message\. /, '') + ' A message that needs nothing back is settled with quilt_inbox (no_reply: [its id]), not answered. ' +
@@ -107,6 +107,7 @@ export const MCP_INSTRUCTIONS =
107
107
  'You are a member of your own in the session, apart from your person and their other AI sessions, named "<their first name> · <label>" after your work (your git branch, or what you first say you are doing); rename yourself with quilt_name_session. Your messages, inbox, claims and duties are your own. People see all of a person\'s AI sessions as one, "<person>\'s AI" ("Daniel\'s AI"): write to another person\'s AI by that name, and what is written to your person\'s AI reaches whichever of their sessions was active last (with none of them live, their cloud AI, if they connected one: Quilt runs their own AI tool for them on their own cloud VM). ' +
108
108
  'Share what you are doing with quilt_share: when you start on a request (request and your plan) and when you finish (what you did and the files you changed). Partners see it in their feed, it puts your work on the task board, and it tells the host not to commit under you. ' +
109
109
  'When Claude Code is started with the quilt channel, they arrive on their own as <channel source="quilt"> events while you work: treat each like a request from that person, answer with quilt_message, and take a task with quilt_move_task. ' +
110
+ RUN_GUIDE + ' ' +
110
111
  TASK_WORKFLOW
111
112
 
112
113
  export async function runMcp () {
@@ -439,39 +440,29 @@ export async function runMcp () {
439
440
  return n ? `Withdrew your request for ${rel}.` : `You had not asked for ${rel}.`
440
441
  }))
441
442
 
442
- const did = (m, deleted = false) => mergeAction(m, deleted)
443
+ // Quilt merges on its own (session.js autoSettle): these say what it did, and what waits for a holder.
443
444
  const mergeLine = (m, me) => {
444
445
  const who = m.by === me ? 'you' : m.by
445
- const other = m.others[0] ? (m.others[0] === me ? 'you' : m.others[0]) : 'the session'
446
- const what = m.kind === 'ai' ? `merged by AI, waiting for a look`
447
- : m.kind === 'claimed' ? `${who} ${did(m)} but ${m.claimedBy} has it claimed`
448
- : m.oursDeleted ? `${who} ${did(m, true)} and ${other} changed it in the session`
449
- : m.theirsHash === null ? `${who} ${did(m)} but it was deleted in the session`
450
- : `${who} ${did(m)} and ${other} changed it in the session`
451
- return `- \`${m.path}\` (id ${m.id}, ${m.state}): ${what}${m.reason ? ` — ${m.reason}` : ''}`
446
+ const what = m.state !== 'done' ? `${who} ${mergeAction(m)}; ${m.claimedBy || 'someone'} has it claimed, and it goes in, merged by Quilt, when they let go`
447
+ : m.reason || 'merged by Quilt'
448
+ return `- \`${m.path}\`: ${what}`
452
449
  }
453
450
 
454
451
  server.registerTool('quilt_merges', {
455
- description: 'Files changed in two places (offline or by a pull, and in the session). Quilt never combines two different changes on its own. Each has an id. The file currently holds the session\'s version; the other version is under .quilt/merges/<id>/ours (with base and theirs beside it). To settle one yourself: write the merged file, then call quilt_resolve_merge with how "agent".',
452
+ description: 'What Quilt merged on its own recently: files changed in two places (offline, by a pull or on GitHub, and in the session). Quilt combines them itself; where both changed the same lines the newer edit stays and the other lines are kept in the file\'s history. Nothing here is for you to settle.',
456
453
  inputSchema: {}
457
454
  }, () => withDaemon(async (d) => {
458
455
  const { merges } = await call(d, 'GET', '/merges')
459
456
  const me = (await call(d, 'GET', '/info')).name
460
- const open = merges.filter((m) => m.state !== 'done')
461
- if (!open.length) return 'Nothing to merge.'
462
- return `Merges to settle:\n${open.map((m) => mergeLine(m, me)).join('\n')}`
457
+ if (!merges.length) return 'Quilt has merged nothing lately.'
458
+ return `Quilt merges on its own; nothing to do:\n${merges.map((m) => mergeLine(m, me)).join('\n')}`
463
459
  }))
464
460
 
461
+ // Kept for guides and prompts from before Quilt merged on its own.
465
462
  server.registerTool('quilt_resolve_merge', {
466
- description: 'Settle a merge from quilt_merges. how: "agent" after you wrote the merged file yourself; "mine" to keep the offline version; "theirs" to keep the session version; "review" to accept an AI merge as it is.',
467
- inputSchema: {
468
- id: z.string().describe('The merge id'),
469
- how: z.enum(['agent', 'mine', 'theirs', 'review']).describe('How it was settled')
470
- }
471
- }, ({ id, how }) => withDaemon(async (d) => {
472
- const r = await call(d, 'POST', '/merges/resolve', { id, how })
473
- return `Settled the merge of ${r.path} (${how}).`
474
- }, { gate: gateFor('quilt_resolve_merge') }))
463
+ description: 'Not needed any more: Quilt merges on its own. Kept so older instructions get an answer.',
464
+ inputSchema: { id: z.string().optional(), how: z.string().optional() }
465
+ }, () => ({ content: [{ type: 'text', text: 'Quilt merges on its own now: there is nothing to settle.' }] }))
475
466
 
476
467
  server.registerTool('quilt_clash', {
477
468
  description: CLASH_DESCRIPTION,
@@ -646,7 +637,7 @@ export async function runMcp () {
646
637
  description: 'Download a file someone shared in chat. Received files are normally saved automatically (see quilt_read_messages); use this to fetch one again or save it into the project.',
647
638
  inputSchema: {
648
639
  id: z.string().describe('The message id shown next to the file'),
649
- dest: z.string().optional().describe('Destination path or folder inside the project (default: .quilt/inbox/)')
640
+ dest: z.string().optional().describe('Destination path or folder inside the project (default: the "Chat files" folder)')
650
641
  }
651
642
  }, ({ id, dest }) => withDaemon(async (d) => {
652
643
  const r = await call(d, 'POST', '/get', { id, dest: dest ? insideProject(d.dir, dest) : undefined })
@@ -961,7 +952,7 @@ export async function runMcp () {
961
952
  }, () => withDaemon(async (d) => { const r = await call(d, 'GET', '/sync-status'); return syncStatusText(r.branches || [], { mine: r.mine || '' }) }))
962
953
 
963
954
  server.registerTool('quilt_sync_branch', {
964
- description: 'Bring commits made outside the session into it now: fetches this folder\'s upstream (say origin/main after a PR merged, or a push from a worktree) and moves the branch forward, with the session\'s uncommitted work merged in. Quilt also does this by itself about once a minute; call it right after you push or merge elsewhere. It says when the fetch fails (git has no credentials here, say). A folder that is not a git clone, or can\'t fetch, asks the relay to bring the commits in from GitHub instead. It never merges git history: a branch that has diverged, or files that clash with the session\'s work, become one task on the board for one AI to resolve (the others are told to leave those files to them).',
955
+ description: 'Bring commits made outside the session into it now: fetches this folder\'s upstream (say origin/main after a PR merged, or a push from a worktree) and moves the branch forward, with the session\'s uncommitted work merged in. Quilt also does this by itself about once a minute; call it right after you push or merge elsewhere. It says when the fetch fails (git has no credentials here, say). A folder that is not a git clone, or can\'t fetch, asks the relay to bring the commits in from GitHub instead. Files that both the commits and the session changed are merged by Quilt on its own.',
965
956
  inputSchema: {}
966
957
  }, () => withDaemon(async (d) => describeBranchSync(await call(d, 'POST', '/branches/sync'))))
967
958
 
@@ -1004,6 +995,11 @@ export async function runMcp () {
1004
995
  inputSchema: {}
1005
996
  }, () => withDaemon(async (d) => (await call(d, 'GET', '/rules')).text))
1006
997
 
998
+ server.registerTool('quilt_secret_names', {
999
+ description: SECRET_NAMES_DESCRIPTION,
1000
+ inputSchema: {}
1001
+ }, () => withDaemon(async (d) => describeSecretNames({ ...(await call(d, 'GET', '/secrets/names')), hosted: false })))
1002
+
1007
1003
  server.registerTool('quilt_session_info', {
1008
1004
  description: 'Where the shared project lives on disk, how you appear to others, who is online, and the invite link.',
1009
1005
  inputSchema: {}
@@ -1075,6 +1071,7 @@ export async function runMcp () {
1075
1071
  const notes = []
1076
1072
  if (f.edited && now - f.edited.ts < 10 * 60 * 1000) notes.push(`edited by ${f.edited.by === me ? 'you' : f.edited.by} ${Math.round((now - f.edited.ts) / 1000)}s ago`)
1077
1073
  if (f.claim) notes.push(`claimed by ${f.claim.by === me ? 'you' : f.claim.by}${f.claim.note ? `: ${f.claim.note}` : ''}${f.claim.queue && f.claim.queue.length ? `, ${f.claim.queue.length} waiting` : ''}`)
1074
+ if (f.chat) notes.push(`sent in chat by ${f.chat.by === me ? 'you' : f.chat.by}: in your folder only, never synced or committed`)
1078
1075
  if (f.binary) notes.push('binary')
1079
1076
  return `${f.path}${notes.length ? ` [${notes.join('; ')}]` : ''}`
1080
1077
  })
package/src/merge3.js CHANGED
@@ -1,9 +1,8 @@
1
1
  // Three-way merge of text, line by line, the way git does it: changes to
2
2
  // different lines combine on their own; the same lines changed on both sides
3
- // are a conflict. Quilt never uses it to combine two different changes on its
4
- // own (a person settles those): it writes the markers for Edit by hand, and
5
- // lays out a settled clash with the commits around it (relay-upstream.js).
6
- import { diff3Merge } from 'node-diff3'
3
+ // are a conflict. Quilt settles those itself (mergeAuto): nobody is asked to merge
4
+ // (docs/superpowers/specs/2026-10-18-automatic-merges.md).
5
+ import { diff3Merge, diffIndices } from 'node-diff3'
7
6
 
8
7
  export const MARK = { start: '<<<<<<< mine', mid: '=======', end: '>>>>>>> session' }
9
8
 
@@ -121,20 +120,103 @@ export function mergeSmart (base, ours, theirs, { union = false } = {}) {
121
120
  for (const r of regions) {
122
121
  if (r.ok) { out.push(...r.ok); continue }
123
122
  const c = r.conflict
124
- if (union) { out.push(...c.a, ...c.b); step('union'); continue }
125
- const both = c.o.length === 0 ? addedBoth(c.a, c.b) : sameImport(c.o, c.a, c.b)
126
- if (both) { out.push(...both); continue }
127
- if (sameIgnoringWs(c.a, c.b)) { out.push(...c.a); step('whitespace'); continue } // the same change, spaced differently
128
- if (sameIgnoringWs(c.a, c.o)) { out.push(...c.b); step('whitespace'); continue } // ours only reindented
129
- if (sameIgnoringWs(c.b, c.o)) { out.push(...c.a); step('whitespace'); continue } // theirs only reindented
130
- const words = mergeWords(c.o, c.a, c.b)
131
- if (words) { out.push(...words); step('words'); continue }
123
+ const settled = ladder(c, { union })
124
+ if (settled) { out.push(...settled.lines); step(settled.how); continue }
132
125
  conflicts.push({ base: c.o, ours: c.a, theirs: c.b })
133
126
  out.push(...c.a)
134
127
  }
135
128
  return { text: out.join('\n'), conflicts, how }
136
129
  }
137
130
 
131
+ /** mergeSmart's steps for one region both sides changed ({ o, a, b }): { lines, how }, or null when none settles it. */
132
+ function ladder (c, { union = false } = {}) {
133
+ if (union) return { lines: [...c.a, ...c.b], how: 'union' }
134
+ const both = c.o.length === 0 ? addedBoth(c.a, c.b) : sameImport(c.o, c.a, c.b)
135
+ if (both) return { lines: both, how: 'lines' }
136
+ if (sameIgnoringWs(c.a, c.b)) return { lines: c.a, how: 'whitespace' } // the same change, spaced differently
137
+ if (sameIgnoringWs(c.a, c.o)) return { lines: c.b, how: 'whitespace' } // ours only reindented
138
+ if (sameIgnoringWs(c.b, c.o)) return { lines: c.a, how: 'whitespace' } // theirs only reindented
139
+ const words = mergeWords(c.o, c.a, c.b)
140
+ if (words) return { lines: words, how: 'words' }
141
+ return null
142
+ }
143
+
144
+ /**
145
+ * How Quilt combines two versions on its own: mergeSmart's steps, then each region both sides
146
+ * changed differently goes to the newer side (`oursNewer`). `lost` lists the lines that lost:
147
+ * { side: 'ours' | 'theirs', lines, kept: the lines that won, at: their first line in `text` },
148
+ * for the file's history and the notice to whoever wrote them.
149
+ */
150
+ export function mergeAuto (base, ours, theirs, { oursNewer = false, union = false } = {}) {
151
+ const regions = diff3Merge(lines(ours), lines(base), lines(theirs), { excludeFalseConflicts: true })
152
+ const out = []
153
+ const lost = []
154
+ const newer = (a, b, at) => {
155
+ const [won, lose, side] = oursNewer ? [a, b, 'theirs'] : [b, a, 'ours']
156
+ lost.push({ side, lines: lose, kept: won, at })
157
+ return won
158
+ }
159
+ for (const r of regions) {
160
+ if (r.ok) { out.push(...r.ok); continue }
161
+ const c = r.conflict
162
+ const settled = ladder(c, { union })
163
+ if (settled) { out.push(...settled.lines); continue }
164
+ // Changes next to each other, not over each other (one side edits a line, the other adds one
165
+ // after it): both go in.
166
+ const side = sideBySide(c.o, c.a, c.b)
167
+ if (side) { out.push(...side); continue }
168
+ // The same lines on all three sides (changes next to each other): line by line, so a line only
169
+ // one side changed keeps that change.
170
+ if (c.o.length === c.a.length && c.o.length === c.b.length) {
171
+ for (let i = 0; i < c.o.length; i++) {
172
+ const [o, a, b] = [c.o[i], c.a[i], c.b[i]]
173
+ if (a === b || b === o) out.push(a)
174
+ else if (a === o) out.push(b)
175
+ else out.push(...newer([a], [b], out.length))
176
+ }
177
+ continue
178
+ }
179
+ out.push(...newer(c.a, c.b, out.length))
180
+ }
181
+ return { text: out.join('\n'), lost }
182
+ }
183
+
184
+ /** Whether two hunks (one per side) change the same base lines, or one adds lines inside the other's. */
185
+ function overlaps (x, y) {
186
+ if (x.len && y.len) return x.at < y.at + y.len && y.at < x.at + x.len
187
+ if (!x.len && !y.len) return x.at === y.at // both add at one place: which goes first would be a guess
188
+ const [add, change] = x.len ? [y, x] : [x, y]
189
+ return add.at > change.at && add.at < change.at + change.len
190
+ }
191
+
192
+ /**
193
+ * One region both sides changed, when their edits only touch: each side's edits from base
194
+ * (diffIndices hunks) are applied together, in base order, when no base line is changed by both,
195
+ * and no line is added where the other side changes lines. Returns the lines, or null.
196
+ */
197
+ function sideBySide (o, a, b) {
198
+ const hunks = [
199
+ ...diffIndices(o, a).map((h) => ({ at: h.buffer1[0], len: h.buffer1[1], lines: h.buffer2Content, side: 0 })),
200
+ ...diffIndices(o, b).map((h) => ({ at: h.buffer1[0], len: h.buffer1[1], lines: h.buffer2Content, side: 1 }))
201
+ ].sort((x, y) => x.at - y.at || x.len - y.len || x.side - y.side)
202
+ for (let i = 0; i < hunks.length; i++) {
203
+ for (let j = i + 1; j < hunks.length; j++) {
204
+ const [x, y] = [hunks[i], hunks[j]]
205
+ if (x.side === y.side) continue
206
+ if (overlaps(x, y)) return null
207
+ }
208
+ }
209
+ const out = []
210
+ let pos = 0
211
+ for (const h of hunks) {
212
+ out.push(...o.slice(pos, h.at))
213
+ out.push(...h.lines)
214
+ pos = Math.max(pos, h.at + h.len)
215
+ }
216
+ out.push(...o.slice(pos))
217
+ return out
218
+ }
219
+
138
220
  /** The paths a .gitattributes text marks merge=union, as a matcher: (rel) => boolean. */
139
221
  export function unionMatcher (texts) {
140
222
  const patterns = []
@@ -165,12 +165,19 @@ export function agentPasses ({ name, dir, fetch: fetchImpl = globalThis.fetch, n
165
165
  * this computer's account. `name` is the name saved on this computer, so a
166
166
  * session can start before (or without, when offline) its first pass.
167
167
  */
168
+ /** The accounts API as this computer's account, signed in now (a sign-in since is picked up). */
169
+ export async function personApiAuth () {
170
+ const account = readAccount()
171
+ if (!account) throw new Error(NOT_SIGNED_IN)
172
+ return { api: apiUrl(), token: account.token }
173
+ }
174
+
168
175
  export function sessionPasses ({ agent = null, dir } = {}) {
169
176
  if (agent) {
170
177
  const saved = readAgent({ name: agent, dir })
171
- return { passes: agentPasses({ name: agent, dir }), identity: saved.identity, kind: 'agent', name: saved.name || agent }
178
+ return { passes: agentPasses({ name: agent, dir }), identity: saved.identity, kind: 'agent', name: saved.name || agent, apiAuth: async () => { const s = await agentAccess({ name: agent, dir }); return { api: s.api, token: s.accessKey } } }
172
179
  }
173
180
  const account = readAccount()
174
181
  if (!account) throw new Error(NOT_SIGNED_IN)
175
- return { passes: personPasses({ token: account.token }), identity: null, kind: 'human', name: account.account.name || null }
182
+ return { passes: personPasses({ token: account.token }), identity: null, kind: 'human', name: account.account.name || null, apiAuth: personApiAuth }
176
183
  }
package/src/pathrules.js CHANGED
@@ -9,11 +9,18 @@ export const LARGE_FILE_BYTES = 256 * 1024
9
9
  // The most a stored file may be (the relay may allow less).
10
10
  export const MAX_STORED_BINARY_BYTES = 100 * 1024 * 1024
11
11
 
12
+ /**
13
+ * Where each person's app saves the files sent in the session's chat (session.js): only those
14
+ * who can see the message get the file, so the folder is never synced, committed or pushed.
15
+ */
16
+ export const CHAT_FILES = 'Chat files'
17
+
12
18
  // Never synced, regardless of .gitignore. .env files are excluded so secrets
13
19
  // stay on each person's machine; Claude Code worktrees are whole private
14
20
  // copies of the project.
15
21
  export const ALWAYS_IGNORED = [
16
22
  '.git', '.quilt', '.cowove', 'node_modules', '.DS_Store', 'Thumbs.db', '.claude/worktrees',
23
+ `/${CHAT_FILES}`,
17
24
  // Each person's own Claude Code settings (permissions, Quilt's hooks) stay on their machine.
18
25
  '.claude/settings.local.json',
19
26
  // Build output and caches: large, machine-specific, and rebuilt by each person.