heyquilt 0.3.121 → 0.3.138

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 (129) hide show
  1. package/RELEASES.md +292 -17
  2. package/bin/quilt.js +72 -5
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -1
  5. package/src/agent-task-workflow.js +2 -2
  6. package/src/agents/common.js +13 -0
  7. package/src/ai-vm.js +9 -1
  8. package/src/api/activity.js +5 -0
  9. package/src/api/memory-store.js +21 -10
  10. package/src/api/notify.js +134 -0
  11. package/src/api/routes/browsers.js +29 -0
  12. package/src/api/routes/github.js +0 -13
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/seam-connect.js +103 -0
  15. package/src/api/routes/session-migrate.js +39 -0
  16. package/src/api/routes/sessions.js +73 -1
  17. package/src/api/server.js +46 -12
  18. package/src/api/supabase-store.js +53 -4
  19. package/src/autowork.js +182 -16
  20. package/src/connection.js +39 -9
  21. package/src/control.js +139 -13
  22. package/src/force-date.js +27 -0
  23. package/src/fsutil.js +13 -3
  24. package/src/gitstate.js +38 -2
  25. package/src/history.js +25 -1
  26. package/src/mcp.js +93 -7
  27. package/src/merge3.js +11 -3
  28. package/src/now-summary.js +113 -0
  29. package/src/pathrules.js +12 -0
  30. package/src/protocol.js +10 -2
  31. package/src/quilt-default.js +84 -0
  32. package/src/quilt-history.js +106 -0
  33. package/src/quilt-remote.js +78 -0
  34. package/src/relay-commit.js +5 -0
  35. package/src/relay-lease.js +6 -2
  36. package/src/relay-mcp.js +172 -8
  37. package/src/relay-outbound.js +1 -1
  38. package/src/relay-prune.js +4 -1
  39. package/src/release-words.js +2 -2
  40. package/src/runner.js +20 -7
  41. package/src/seam-access.js +97 -0
  42. package/src/seam-claims.js +53 -0
  43. package/src/seam-url.js +15 -0
  44. package/src/server.js +1767 -33
  45. package/src/session-follow.js +515 -0
  46. package/src/session-layout3.js +466 -0
  47. package/src/session-migrate.js +123 -0
  48. package/src/session-quilt.js +145 -0
  49. package/src/session-seam.js +284 -0
  50. package/src/session.js +899 -46
  51. package/src/source-live/bring-in.js +283 -0
  52. package/src/source-live/live-block.js +507 -35
  53. package/src/source-live/live-square.js +17 -6
  54. package/src/status.js +5 -0
  55. package/src/ui/agent-guide.js +1 -1
  56. package/src/ui/app.css +85 -20
  57. package/src/ui/app.js +4 -1
  58. package/src/ui/chat.js +9 -3
  59. package/src/ui/cloud-ai-model.js +13 -0
  60. package/src/ui/cloud-ai.js +10 -2
  61. package/src/ui/common.js +6 -2
  62. package/src/ui/fileview.js +20 -7
  63. package/src/ui/force-words.js +19 -0
  64. package/src/ui/git-rule.js +9 -0
  65. package/src/ui/home.js +0 -147
  66. package/src/ui/loom-model.js +15 -1
  67. package/src/ui/quiltview.js +213 -0
  68. package/src/ui/seam-model.js +251 -0
  69. package/src/ui/session.js +413 -31
  70. package/src/ui/voice-call.js +83 -14
  71. package/src/ui/voice-model.js +39 -0
  72. package/src/ui/voice.js +167 -17
  73. package/src/ui-server.js +80 -28
  74. package/src/update-check.js +25 -1
  75. package/src/voice.js +13 -6
  76. package/src/git.js +0 -147
  77. package/src/quilts/AGENTS.md +0 -1
  78. package/src/quilts/CLAUDE.md +0 -1
  79. package/src/quilts/archiver.js +0 -175
  80. package/src/quilts/autorevert.js +0 -49
  81. package/src/quilts/bisect-run.js +0 -31
  82. package/src/quilts/blame.js +0 -103
  83. package/src/quilts/block.js +0 -347
  84. package/src/quilts/bucket.js +0 -25
  85. package/src/quilts/cbor.js +0 -92
  86. package/src/quilts/claims.js +0 -144
  87. package/src/quilts/cli.js +0 -449
  88. package/src/quilts/client.js +0 -128
  89. package/src/quilts/conflict-format.js +0 -190
  90. package/src/quilts/describe.js +0 -266
  91. package/src/quilts/dogfood.js +0 -82
  92. package/src/quilts/errors.js +0 -8
  93. package/src/quilts/events.js +0 -160
  94. package/src/quilts/export-git.js +0 -130
  95. package/src/quilts/export.js +0 -81
  96. package/src/quilts/fastmerge.js +0 -41
  97. package/src/quilts/hash.js +0 -44
  98. package/src/quilts/history.js +0 -266
  99. package/src/quilts/ids.js +0 -12
  100. package/src/quilts/import-git.js +0 -291
  101. package/src/quilts/import.js +0 -134
  102. package/src/quilts/index.js +0 -72
  103. package/src/quilts/limits.js +0 -67
  104. package/src/quilts/lines.js +0 -49
  105. package/src/quilts/live.js +0 -50
  106. package/src/quilts/merge-smart.js +0 -114
  107. package/src/quilts/merge.js +0 -486
  108. package/src/quilts/objects.js +0 -144
  109. package/src/quilts/pack.js +0 -118
  110. package/src/quilts/records.js +0 -211
  111. package/src/quilts/resolutions.js +0 -69
  112. package/src/quilts/reviews.js +0 -195
  113. package/src/quilts/routes.js +0 -126
  114. package/src/quilts/service.js +0 -923
  115. package/src/quilts/setup.js +0 -34
  116. package/src/quilts/signing.js +0 -149
  117. package/src/quilts/squares.js +0 -239
  118. package/src/quilts/store-memory.js +0 -683
  119. package/src/quilts/store-rpc.js +0 -79
  120. package/src/quilts/store-supabase.js +0 -13
  121. package/src/quilts/tags.js +0 -64
  122. package/src/quilts/textdiff.js +0 -96
  123. package/src/quilts/tokens.js +0 -66
  124. package/src/quilts/tools.js +0 -375
  125. package/src/quilts/tree.js +0 -351
  126. package/src/quilts/webhook.js +0 -116
  127. package/src/quilts/window.js +0 -119
  128. package/src/quilts/xdiff.js +0 -435
  129. package/src/ui/branches.js +0 -111
package/src/ui-server.js CHANGED
@@ -15,7 +15,6 @@ import { MAX_SHARED_FILE_BYTES } from './protocol.js'
15
15
  import { getSettings, saveSettings, unsupportedRelay, relayUrl } from './settings.js'
16
16
  import { PICKUP_MODES, pickupMode } from './tasks.js'
17
17
  import { AGENT_COMMITS } from './github-commit.js'
18
- import * as gitops from './git.js'
19
18
  import { execFile } from 'node:child_process'
20
19
  import { installedEditors, openIn, copyToClipboard } from './editors.js'
21
20
  import { autowork, autoConfig, publicConfig, AUTO_MODES } from './autowork.js'
@@ -28,6 +27,7 @@ import { effectiveAccess, builtinType } from './session-access.js'
28
27
  import { cleanSessionName, BAD_SESSION_NAME, SESSION_NAME_MAX } from './session-name.js'
29
28
  import { personPasses, personApiAuth } from './pass-source.js'
30
29
  import { INVALID_INVITE } from './ui/invite.js'
30
+ import { seamResultWords } from './ui/seam-model.js'
31
31
  import { loadIdentity } from './identity.js'
32
32
  import { currentVersion, localReleases, latestRelease, compareVersions, downloadUrl, seenVersion, markSeen, hiddenUpdate, hideUpdate } from './releases.js'
33
33
  import { createReporter } from './report.js'
@@ -45,6 +45,9 @@ const THEMES = ['light', 'dark', 'system']
45
45
  const SAVE_FAILED = "Quilt couldn't save your sign-in on this computer."
46
46
  const SIGNED_OUT_MESSAGE = 'This computer was signed out. Sign in again.'
47
47
  const LOCAL_RELAY_GONE = "This session ran on your computer's own relay, which Quilt no longer supports. Your files are untouched."
48
+ // 16's R2: no New session from GitHub. Said to an older window that still offers it.
49
+ export const GITHUB_GONE = 'Starting a session from GitHub is gone: start one from a folder (a new session\'s code is committed in Seam from the start), and bring a repository\'s history into Seam with seam github import.'
50
+ const STATUS_PUSH_MS = 500 // a session's status goes to the window at most this often when the session changes it
48
51
  // Until this computer is signed in, only these answer.
49
52
  const OPEN_ROUTES = new Set(['GET /api/account', 'POST /api/account/start', 'POST /api/account/cancel', 'POST /api/account/signout', 'GET /api/events', 'POST /api/shutdown', 'POST /api/report'])
50
53
 
@@ -58,7 +61,7 @@ function profile () {
58
61
  return {
59
62
  name: account ? account.account.name : os.userInfo().username,
60
63
  tool: s.tool || guessTool(),
61
- // No tool picked in Settings: the shown one is a guess, and the AI that connects replaces it.
64
+ // No tool picked in Settings: the shown one is a guess, which saving a colour doesn't pin.
62
65
  toolGuessed: !s.tool,
63
66
  color: s.color || null,
64
67
  joinDir: s.joinDir || '~/quilt',
@@ -120,6 +123,11 @@ function updateProfile (b) {
120
123
  patch.autoModel = m || undefined
121
124
  }
122
125
  if ('autoFiles' in b) patch.autoFiles = b.autoFiles ? true : undefined
126
+ // Whether a run of the person's AI may merge a square under its line's policy (Seam, 12): never unless they say so.
127
+ if ('autoMerge' in b) {
128
+ if (!['never', 'policy'].includes(b.autoMerge)) throw httpError(400, 'Pick Never, or When the line\'s rules allow it.')
129
+ patch.autoMerge = b.autoMerge === 'policy' ? 'policy' : undefined
130
+ }
123
131
  if ('autoCap' in b) {
124
132
  const n = b.autoCap === '' || b.autoCap === null ? 0 : Number(b.autoCap)
125
133
  if (!Number.isInteger(n) || n < 0 || n > 1e9) throw httpError(400, 'The daily limit is a whole number of tokens (0 for none).')
@@ -181,6 +189,7 @@ export const STATIC = {
181
189
  '/changes.js': ['changes.js', 'text/javascript; charset=utf-8'],
182
190
  '/changes-seen.js': ['changes-seen.js', 'text/javascript; charset=utf-8'],
183
191
  '/diffview.js': ['diffview.js', 'text/javascript; charset=utf-8'],
192
+ '/quiltview.js': ['quiltview.js', 'text/javascript; charset=utf-8'],
184
193
  '/boxes.js': ['boxes.js', 'text/javascript; charset=utf-8'],
185
194
  '/releases.js': ['releases.js', 'text/javascript; charset=utf-8'],
186
195
  '/feed-convs.js': ['feed-convs.js', 'text/javascript; charset=utf-8'],
@@ -193,7 +202,6 @@ export const STATIC = {
193
202
  '/loom.css': ['loom.css', 'text/css; charset=utf-8'],
194
203
  '/board.js': ['board.js', 'text/javascript; charset=utf-8'],
195
204
  '/schedule.js': ['schedule.js', 'text/javascript; charset=utf-8'],
196
- '/branches.js': ['branches.js', 'text/javascript; charset=utf-8'],
197
205
  '/workspaces.js': ['workspaces.js', 'text/javascript; charset=utf-8'],
198
206
  '/invites.js': ['invites.js', 'text/javascript; charset=utf-8'],
199
207
  '/agent-place.js': ['agent-place.js', 'text/javascript; charset=utf-8'],
@@ -205,6 +213,8 @@ export const STATIC = {
205
213
  '/preview-model.js': ['preview-model.js', 'text/javascript; charset=utf-8'],
206
214
  '/sounds.js': ['sounds.js', 'text/javascript; charset=utf-8'],
207
215
  '/git-rule.js': ['git-rule.js', 'text/javascript; charset=utf-8'],
216
+ '/seam-model.js': ['seam-model.js', 'text/javascript; charset=utf-8'],
217
+ '/force-words.js': ['force-words.js', 'text/javascript; charset=utf-8'],
208
218
  '/cloud-ai.js': ['cloud-ai.js', 'text/javascript; charset=utf-8'],
209
219
  '/cloud-ai-model.js': ['cloud-ai-model.js', 'text/javascript; charset=utf-8'],
210
220
  '/sandbox-keys.js': ['sandbox-keys.js', 'text/javascript; charset=utf-8'],
@@ -397,8 +407,20 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
397
407
  return { id, dir: r.run.dir, invite: r.run.invite, viewInvite: r.run.viewInvite, status: r.run.session.status(), logs: r.logs.slice(-80), workspace: readConfig(r.run.dir)?.workspace || '' }
398
408
  }
399
409
  const pushStatus = (id) => runs.has(id) && broadcast('session', summary(id))
410
+ // What the session itself changes (presence, status, access) is sent at most every STATUS_PUSH_MS,
411
+ // and only to a window that listens: presence alone changes several times a second in a busy room,
412
+ // and each full status (every task, comment and change) made hundreds of MB a minute of garbage.
413
+ const pushTimers = new Map()
414
+ const pushSoon = (id) => {
415
+ if (pushTimers.has(id) || !runs.has(id)) return
416
+ const t = setTimeout(() => { pushTimers.delete(id); if (clients.size) pushStatus(id) }, STATUS_PUSH_MS)
417
+ t.unref?.()
418
+ pushTimers.set(id, t)
419
+ }
400
420
 
401
- async function start ({ mode, dir, tool, invite, prefer, repo, branch, newBranch, base, workspace }) {
421
+ async function start ({ mode, dir, tool, invite, prefer, workspace }) {
422
+ // New session from GitHub went in 16's R2: new sessions are quilts (quilt-default.js), and a repository's history comes into Seam by its import.
423
+ if (mode === 'github') throw httpError(410, GITHUB_GONE)
402
424
  const me = profile()
403
425
  // Every session signs in to the relay as this computer's account.
404
426
  const sessionPasses = accountPasses()
@@ -407,16 +429,6 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
407
429
  const outlived = () => readAccount()
408
430
  ? httpError(409, 'Your sign-in changed while this session was starting. Start it again.')
409
431
  : Object.assign(httpError(401, 'Sign in to Quilt first.'), { signedOut: true })
410
- if (mode === 'github') {
411
- // Clone first, then start a normal session on the clone.
412
- const repoName = String(repo || '').split('/').pop()
413
- dir = path.resolve(expandHome(dir || underJoinDir(me.joinDir, repoName || 'repo', 'That repository name is not valid.')))
414
- if (runs.has(idFor(dir))) throw httpError(400, 'A session is already running in that folder.')
415
- await gitops.cloneRepo({ repo, dir, branch, newBranch, base })
416
- if (!current()) throw outlived()
417
- mode = 'create'
418
- }
419
- const toolGuessed = !tool && me.toolGuessed
420
432
  tool = tool || me.tool
421
433
  prefer = prefer || (me.preferLocal ? 'local' : 'remote')
422
434
  if (mode === 'join' && !dir) {
@@ -457,7 +469,6 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
457
469
  // The account's name until the first pass names us (the same name, from the API).
458
470
  name: me.name,
459
471
  tool,
460
- toolGuessed,
461
472
  color: me.color,
462
473
  shareByDefault: me.shareAgent,
463
474
  summarizeByDefault: me.summarize,
@@ -471,11 +482,14 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
471
482
  startName: mode === 'create' ? cleanSessionName([...path.basename(dir)].slice(0, SESSION_NAME_MAX).join('')) || '' : '',
472
483
  // Your default for what agents may commit, set on the new room once the relay makes you its owner.
473
484
  startAgentCommits: mode === 'create' ? me.agentCommits : '',
485
+ // A new session is a quilt from the start when the API says so (quilt-default.js).
486
+ newSession: mode === 'create',
474
487
  workspace: workspace || '',
475
488
  onLog: log,
476
489
  onFatal: async (err) => {
477
490
  log(`stopped: ${err.message}`)
478
- await stop(id)
491
+ // Said in the window too (app.js): the relay sent this app away (to update, ended, not let in), or it couldn't go on.
492
+ await stop(id, err.message)
479
493
  // Only this sign-in's passes may sign it out: a stale session's 401 is about a token already gone.
480
494
  if (err.signedOut && current()) await signedOut('revoked')
481
495
  }
@@ -495,13 +509,15 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
495
509
  }
496
510
  runs.set(id, entry)
497
511
  const s = entry.run.session
498
- s.on('status-changed', () => pushStatus(id))
499
- s.on('access', () => pushStatus(id))
512
+ s.on('status-changed', () => pushSoon(id))
513
+ s.on('access', () => pushSoon(id))
514
+ s.on('quilt', () => pushSoon(id))
515
+ s.on('quilt-shadow', () => pushSoon(id))
500
516
  s.on('message', (m) => broadcast('message', { id, message: m }))
501
517
  s.on('agent-feed', (entries) => broadcast('feed', { id, entries }))
502
518
  s.on('file-changed', (e) => broadcast('file-changed', { id, ...e }))
503
519
  // Presence changes (e.g. focus, recently edited files) also refresh the view.
504
- s.conn.awareness.on('change', () => pushStatus(id))
520
+ s.conn.awareness.on('change', () => pushSoon(id))
505
521
  // The call (voice.js): who is in it and their call setup, sent only when it changed.
506
522
  let voiceWas = ''
507
523
  const pushVoice = () => {
@@ -518,7 +534,7 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
518
534
  if (workspace && mode === 'create') {
519
535
  // Put the new room in its workspace before anyone else connects. A failure is logged, never
520
536
  // fatal, and the session is outside any workspace here too, as it is on the API.
521
- // mode was reassigned to 'create' for github clones above, so this covers both.
537
+ // A new session from a folder (New session from GitHub went in 16's R2).
522
538
  const linked = await asAccount((token) => setSessionWorkspace({ token, id: workspace, room: conn.room })).then(() => true, (err) => {
523
539
  log(`could not add this session to its workspace: ${err.message}`)
524
540
  forgetWorkspace(entry.run.dir)
@@ -537,12 +553,13 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
537
553
  return summary(id)
538
554
  }
539
555
 
540
- async function stop (id) {
556
+ /** `why`: the session stopped by itself (Session 'fatal'), not because someone left it: the window says why. */
557
+ async function stop (id, why = '') {
541
558
  const r = runs.get(id)
542
559
  if (!r) return
543
560
  runs.delete(id)
544
561
  await r.run.stop()
545
- broadcast('stopped', { id })
562
+ broadcast('stopped', { id, ...(why ? { why: String(why).slice(0, 500) } : {}) })
546
563
  }
547
564
 
548
565
  const get = (id) => {
@@ -1032,7 +1049,7 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
1032
1049
  'POST /api/sessions/:id/focus': (b, id) => { get(id).setFocus(b.text); return { ok: true } },
1033
1050
  // Voice (voice.js): the call as this app sees it, and our part in it, from the app window, which does the audio.
1034
1051
  'GET /api/sessions/:id/voice': (b, id) => get(id).voice(),
1035
- 'POST /api/sessions/:id/voice': (b, id) => get(id).setVoice(b.in === false ? null : { muted: b.muted === true, sig: b.sig, transcribing: b.transcribing === true }),
1052
+ 'POST /api/sessions/:id/voice': (b, id) => get(id).setVoice(b.in === false ? null : { muted: b.muted === true, sig: b.sig, transcribing: b.transcribing === true, sharing: b.sharing === true }),
1036
1053
  'POST /api/sessions/:id/voice/transcribe': (b, id) => get(id).setVoiceTranscribe(b.on === true),
1037
1054
  'POST /api/sessions/:id/voice/clip': (b, id) => voiceClip(get(id), b),
1038
1055
  'GET /api/sessions/:id/voice/transcript': (b, id, url) => get(id).voiceTranscript({ call: url.searchParams.get('call') || '' }),
@@ -1085,12 +1102,22 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
1085
1102
  'GET /api/sessions/:id/changes': (b, id) => get(id).changes(),
1086
1103
  'POST /api/sessions/:id/catch-up/dismiss': (b, id) => { const r = get(id).dismissCatchUp(); pushStatus(id); return r },
1087
1104
  // One file's changes, newest first, each with its diff (the Changes panel's "What changed").
1088
- 'GET /api/sessions/:id/history': (b, id, url) => {
1105
+ // In a session linked to a quilt, its History: the live changes, then the patches from Seam's log.
1106
+ 'GET /api/sessions/:id/history': async (b, id, url) => {
1089
1107
  const file = String(url.searchParams.get('path') || '')
1090
- const entries = get(id).history.entries().filter((e) => e.path === file)
1108
+ const s = get(id)
1109
+ const linked = s.quiltLink() ? await s.fileHistory(file) : null
1110
+ if (linked) return { entries: linked.live, patches: linked.patches, quilt: linked.quilt, ...(linked.note ? { note: linked.note } : {}) }
1111
+ const entries = s.history.entries().filter((e) => e.path === file)
1091
1112
  return { entries: queryHistory(entries, { limit: 100 }).reverse() }
1092
1113
  },
1114
+ // A linked session's Blame of a file, one patch of its History, and code search (the relay reads Seam).
1115
+ 'GET /api/sessions/:id/blame': (b, id, url) => get(id).quiltBlame(String(url.searchParams.get('path') || '')),
1116
+ 'GET /api/sessions/:id/patch': (b, id, url) => get(id).quiltShow(String(url.searchParams.get('commit') || ''), String(url.searchParams.get('path') || '')),
1117
+ 'GET /api/sessions/:id/search': (b, id, url) => get(id).quiltSearch(String(url.searchParams.get('q') || '')),
1093
1118
  // Commits pushed or merged elsewhere: fetch and bring them in now (they also come in by themselves).
1119
+ // In a session that moved into Seam: the square panel's Commit and Merge onto main, as this member (control.js seamTool).
1120
+ 'POST /api/sessions/:id/seam': (b, id) => squareTool(get(id), b),
1094
1121
  'POST /api/sessions/:id/branches/sync': async (b, id) => { const r = await get(id).syncBranchNow(); pushStatus(id); return r },
1095
1122
  'GET /api/sessions/:id/file': (b, id, url) => {
1096
1123
  const f = get(id).readShared(url.searchParams.get('path'))
@@ -1170,14 +1197,17 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
1170
1197
  reporter.record({ kind: 'error', name: String(b.name || 'renderer').slice(0, 80), outcome: 'error', message: String(b.message || '').slice(0, 500), context: typeof b.context === 'object' && b.context ? b.context : {} })
1171
1198
  return { ok: true }
1172
1199
  },
1173
- 'GET /api/github/status': () => gitops.ghStatus(),
1174
- 'GET /api/github/repos': async (b, id, url) => ({ repos: await gitops.listRepos({ limit: url.searchParams.get('limit') || 100 }) }),
1175
- 'GET /api/github/branches': (b, id, url) => gitops.listBranches(url.searchParams.get('repo')),
1200
+ // New session from GitHub's lists, gone in 16's R2: an older window that asks is told why.
1201
+ 'GET /api/github/status': () => { throw httpError(410, GITHUB_GONE) },
1202
+ 'GET /api/github/repos': () => { throw httpError(410, GITHUB_GONE) },
1203
+ 'GET /api/github/branches': () => { throw httpError(410, GITHUB_GONE) },
1176
1204
  'POST /api/sessions/:id/commit-request': (b, id) => { const r = get(id).requestCommit(b.message, { files: Array.isArray(b.files) ? b.files : null, task: b.task || null, by: b.by ? String(b.by).slice(0, 80) : null }); pushStatus(id); return r },
1177
1205
  // Commits a request in this folder (commit.js), and what is not committed here yet, by who made it.
1178
1206
  'POST /api/sessions/:id/commit-session': async (b, id) => { try { return await get(id).commitSessionWork(String(b.message || ''), { mine: b.mine === true }) } finally { pushStatus(id) } },
1179
1207
  'POST /api/sessions/:id/commit-request/commit': async (b, id) => { try { return await get(id).commitRequest(String(b.id || '')) } finally { pushStatus(id) } },
1180
1208
  'POST /api/sessions/:id/agent-commits': async (b, id) => { try { return await get(id).setAgentCommits(b.mode) } finally { pushStatus(id) } },
1209
+ // "Make this a quilt" (Seam's plans 16, flag 9): the owner's button. The relay makes the session a quilt and moves it into its squares, as the owner.
1210
+ 'POST /api/sessions/:id/quilt-make': async (b, id) => { try { return await get(id).quiltMake(process.env.SEAM_TOKEN || (readAccount() || {}).token) } finally { pushStatus(id) } },
1181
1211
  'POST /api/sessions/:id/github-sync': async (b, id) => { try { return await get(id).setGithubSync(b.on !== false) } finally { pushStatus(id) } },
1182
1212
  'POST /api/sessions/:id/large-to-relay': async (b, id) => { try { return await get(id).setLargeToRelay(b.on === true) } finally { pushStatus(id) } },
1183
1213
  'POST /api/sessions/:id/github/recheck': async (b, id) => { try { return { github: await get(id).githubRecheck() } } finally { pushStatus(id) } },
@@ -1503,6 +1533,28 @@ export function underJoinDir (joinDir, name, message = INVALID_INVITE) {
1503
1533
  return dir
1504
1534
  }
1505
1535
 
1536
+ /**
1537
+ * The square panel's buttons (layout 3): seam_commit with a message, or seam_merge (a message for what it
1538
+ * commits first), as this member through Seam's own tools; or open_square, for a folder whose branch has
1539
+ * none (Session.openSquareHere). The answer as { text }, a refusal as a 400.
1540
+ */
1541
+ export async function squareTool (s, b) {
1542
+ const tool = String((b && b.tool) || '')
1543
+ if (tool === 'open_square') {
1544
+ if (!s.openSquareHere) throw httpError(409, 'This session\'s code is not in Seam.')
1545
+ try { return { text: (await s.openSquareHere()).text } } catch (err) { throw httpError(400, err.message) }
1546
+ }
1547
+ if (tool !== 'seam_commit' && tool !== 'seam_merge') throw httpError(400, 'The app commits or merges the square, or opens one: seam_commit, seam_merge or open_square.')
1548
+ if (!s.seamTool) throw httpError(409, 'This session\'s code is not in Seam.')
1549
+ const message = b.args && typeof b.args.message === 'string' ? b.args.message.trim().slice(0, 2000) : ''
1550
+ if (tool === 'seam_commit' && !message) throw httpError(400, 'Say what the commit is.')
1551
+ const r = await s.seamTool({ tool, args: message ? { message } : {} })
1552
+ const text = ((r && r.content) || []).map((c) => c.text || '').join('\n').trim()
1553
+ if (r && r.isError) throw httpError(400, text || 'Seam refused.')
1554
+ // Plain words for a person (seam-model.js), not the tool's text an AI reads.
1555
+ return { text: seamResultWords(tool, { structuredContent: r && r.structuredContent, text }) || 'Done.' }
1556
+ }
1557
+
1506
1558
  function httpError (status, message) {
1507
1559
  return Object.assign(new Error(message), { status })
1508
1560
  }
@@ -3,7 +3,31 @@
3
3
  // quilt_check_update answers for whatever image the agent names.
4
4
  import { currentVersion, latestRelease, compareVersions } from './releases.js'
5
5
 
6
- export const UPDATE_HOW = 'Update Quilt (the desktop app: Settings → About → Update Quilt; the CLI: npm i -g heyquilt, or on a Linux server run its install script again: curl -fsSL https://heyquilt.com/install.sh | sh), then rejoin the session.'
6
+ // How to update each kind of install. Not npm: the heyquilt package there is behind the releases.
7
+ export const UPDATE_STEPS = 'In the desktop app: Settings → About → Update Quilt. On Linux without a desktop: curl -fsSL https://heyquilt.com/install.sh | sh'
8
+ export const UPDATE_HOW = 'Update Quilt (the desktop app: Settings → About → Update Quilt; on Linux without a desktop, run curl -fsSL https://heyquilt.com/install.sh | sh), then rejoin the session.'
9
+
10
+ /**
11
+ * What the relay tells an app or CLI too old for a session: only what to do. `version` is the oldest
12
+ * Quilt the session takes, when there is one. It fits a WebSocket close frame (123 bytes).
13
+ */
14
+ export const needsUpdateWords = (version = '') => `This session needs ${version ? `Quilt ${version} or later` : 'a newer version of Quilt'}. Update Quilt, then join again.`
15
+
16
+ const UPDATE_REFUSAL = /\bUpdate Quilt, then join again\./
17
+
18
+ /** True when a relay's refusal says to update Quilt (needsUpdateWords). */
19
+ export const isUpdateRefusal = (reason) => UPDATE_REFUSAL.test(String(reason || ''))
20
+
21
+ /**
22
+ * A refusal to update with how to, for each kind of install. `ascii`: for an HTTP status line, which
23
+ * older apps read as Latin-1, so an arrow would come out garbled.
24
+ */
25
+ export function withUpdateSteps (reason, { ascii = false } = {}) {
26
+ const r = String(reason || '')
27
+ const head = isUpdateRefusal(r) ? r.slice(0, r.search(UPDATE_REFUSAL)) + 'Update Quilt, then join again.' : r
28
+ const out = `${head} ${UPDATE_STEPS}`
29
+ return ascii ? out.replace(/→/g, '>') : out
30
+ }
7
31
 
8
32
  /** True when `image` looks like a Quilt version. */
9
33
  export const validImage = (image) => /^v?\d+\.\d+\.\d+$/.test(String(image || '').trim())
package/src/voice.js CHANGED
@@ -1,6 +1,6 @@
1
- // Voice in a session (plans/voice.md). Who is in the call, muted or not, and the call setup
1
+ // Voice in a session (plans/voice.md). Who is in the call, muted or sharing their screen or not, and the call setup
2
2
  // (each pair's WebRTC offer and answer) travel in presence, so the relay needs nothing new.
3
- // The audio itself goes from app window to app window, never through the relay or this
3
+ // The audio (and a shared screen) goes from app window to app window, never through the relay or this
4
4
  // process. What people say, when the owner turned transcripts on and they agreed, is kept as
5
5
  // lines in the session's document for everyone, agents included, to read.
6
6
  import crypto from 'node:crypto'
@@ -50,6 +50,8 @@ export function cleanVoice (raw) {
50
50
  call: typeof raw.call === 'string' && CALL.test(raw.call) ? raw.call : '',
51
51
  // This person's app sends their speech to the transcript (they agreed, and the owner turned it on).
52
52
  ...(raw.transcribing === true ? { transcribing: true } : {}),
53
+ // This person shares their screen with the call.
54
+ ...(raw.sharing === true ? { sharing: true } : {}),
53
55
  sig
54
56
  }
55
57
  }
@@ -133,11 +135,14 @@ export function renderLines ({ lines, more, calls }, { tz } = {}) {
133
135
  export function voiceView ({ awareness, clientID, members = [], doc }) {
134
136
  const me = cleanVoice(awareness.getLocalState()?.voice)
135
137
  const peers = []
138
+ // Everyone in the session now, in voice or not: someone who left voice but not the session hung up.
139
+ const here = []
136
140
  for (const [id, s] of awareness.getStates()) {
137
141
  if (id === clientID || !s || !s.name) continue
142
+ here.push(id)
138
143
  const v = cleanVoice(s.voice)
139
144
  if (!v) continue
140
- peers.push({ id, name: s.name, color: s.color || '', muted: v.muted, since: v.since, call: v.call, ...(v.transcribing ? { transcribing: true } : {}), signal: v.sig[String(clientID)] || null })
145
+ peers.push({ id, name: s.name, color: s.color || '', muted: v.muted, since: v.since, call: v.call, ...(v.transcribing ? { transcribing: true } : {}), ...(v.sharing ? { sharing: true } : {}), signal: v.sig[String(clientID)] || null })
141
146
  }
142
147
  peers.sort((a, b) => a.since - b.since || a.id - b.id)
143
148
  const t = doc ? transcribeSetting(doc, members) : { on: false }
@@ -149,10 +154,12 @@ export function voiceView ({ awareness, clientID, members = [], doc }) {
149
154
  me: clientID,
150
155
  in: !!me,
151
156
  muted: !!(me && me.muted),
157
+ sharing: !!(me && me.sharing),
152
158
  call,
153
159
  since: starts.length ? Math.min(...starts) : 0,
154
160
  transcribe: t.on,
155
161
  peers,
162
+ here,
156
163
  // The latest lines of the call going on now, as each is written down.
157
164
  recent: doc && call ? readLines(doc, { call, limit: RECENT }).lines : [],
158
165
  // Every call that has a transcript: when, who spoke, how many lines.
@@ -160,12 +167,12 @@ export function voiceView ({ awareness, clientID, members = [], doc }) {
160
167
  }
161
168
  }
162
169
 
163
- /** Who is in voice, for status: name -> { muted, transcribing }. */
170
+ /** Who is in voice, for status: name -> { muted, transcribing, sharing }. */
164
171
  export function inVoice (awareness, clientID) {
165
172
  const out = new Map()
166
173
  for (const [id, s] of awareness.getStates()) {
167
174
  const v = s && s.name ? cleanVoice(s.voice) : null
168
- if (v) out.set(s.name, { muted: v.muted, ...(v.transcribing ? { transcribing: true } : {}), ...(id === clientID ? { me: true } : {}) })
175
+ if (v) out.set(s.name, { muted: v.muted, ...(v.transcribing ? { transcribing: true } : {}), ...(v.sharing ? { sharing: true } : {}), ...(id === clientID ? { me: true } : {}) })
169
176
  }
170
177
  return out
171
178
  }
@@ -179,6 +186,6 @@ export function voiceStatusLines (people, transcribe) {
179
186
  if (!people.length) return []
180
187
  return [
181
188
  `## In a voice call${transcribe ? ' (transcribed: quilt_voice_transcript reads it)' : ' (not transcribed: ask in chat what was decided)'}`,
182
- ...people.map((p) => `- ${p.name}${p.muted ? ' (muted)' : ''}${transcribe && !p.transcribing ? ' (not transcribed)' : ''}`)
189
+ ...people.map((p) => `- ${p.name}${p.muted ? ' (muted)' : ''}${p.sharing ? ' (sharing their screen)' : ''}${transcribe && !p.transcribing ? ' (not transcribed)' : ''}`)
183
190
  ]
184
191
  }
package/src/git.js DELETED
@@ -1,147 +0,0 @@
1
- // Git for starting a session from GitHub: checking names, listing repos and
2
- // branches, cloning. Quilt never commits, pulls or pushes. Wraps the `git`
3
- // and `gh` CLIs; arguments are always passed as a list, never through a shell.
4
- import fs from 'node:fs'
5
- import path from 'node:path'
6
- import { execFile } from 'node:child_process'
7
-
8
- // QUILT_GH lets tests stand in a fake `gh`.
9
- const GH = () => process.env.QUILT_GH || 'gh'
10
- const REPO_RE = /^[\w.-]+\/[\w.-]+$/
11
- // Never stop to ask for a password or open an editor.
12
- const ENV = { GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', GIT_EDITOR: 'true', GH_NO_UPDATE_NOTIFIER: '1' }
13
-
14
- /** Runs a command; resolves to stdout, or rejects with a short message from stderr. */
15
- function run (cmd, args, { cwd, allowFail = false } = {}) {
16
- return new Promise((resolve, reject) => {
17
- execFile(cmd, args, { cwd, env: { ...process.env, ...ENV }, maxBuffer: 32 * 1024 * 1024, timeout: 5 * 60 * 1000 }, (err, stdout, stderr) => {
18
- if (!err) return resolve(stdout)
19
- if (allowFail) return resolve(null)
20
- if (err.code === 'ENOENT') return reject(new Error(cmd === GH() ? 'The GitHub CLI (gh) is not installed.' : 'git is not installed.'))
21
- const e = new Error(shortError(stderr || stdout || err.message))
22
- e.stderr = String(stderr || '')
23
- e.exitCode = err.code
24
- reject(e)
25
- })
26
- })
27
- }
28
- const git = (dir, args, opts) => run('git', args, { cwd: dir, ...opts })
29
- const gh = (args, opts) => run(GH(), args, opts)
30
-
31
- /** The first useful line of a CLI error, without "fatal:"/"error:" noise. */
32
- function shortError (text) {
33
- const lines = String(text).split('\n').map((l) => l.trim()).filter(Boolean)
34
- const line = lines.find((l) => /^(fatal|error|remote|GraphQL|HTTP)/i.test(l)) || lines[0] || 'Something went wrong.'
35
- return line.replace(/^(fatal|error):\s*/i, '').replace(/^remote:\s*/i, '').slice(0, 300)
36
- }
37
-
38
- /** Throws unless `name` is a valid branch name. */
39
- export async function checkBranchName (name) {
40
- name = String(name || '').trim()
41
- if (!name) throw new Error('Enter a branch name.')
42
- // Not `--branch`: it expands @{-N} using the current folder's repo, so the
43
- // answer would depend on where quilt happens to run.
44
- if (name.startsWith('-') || name === 'HEAD' || (await run('git', ['check-ref-format', `refs/heads/${name}`], { allowFail: true })) === null) {
45
- throw new Error(`"${name}" isn't a valid branch name.`)
46
- }
47
- return name
48
- }
49
-
50
- function checkRepo (repo) {
51
- repo = String(repo || '').trim()
52
- if (!REPO_RE.test(repo) || repo.split('/').some((p) => /^\.+$/.test(p))) throw new Error('Pick a repository (owner/name).')
53
- return repo
54
- }
55
-
56
- // --------------------------------------------------------------- GitHub --
57
-
58
- /** Is gh installed and signed in? { installed, authenticated, user, message } */
59
- export async function ghStatus () {
60
- const probe = (args) => new Promise((resolve) => {
61
- execFile(GH(), args, { env: { ...process.env, ...ENV }, timeout: 20000 }, (err, stdout, stderr) => resolve({ err, text: `${stdout}\n${stderr}` }))
62
- })
63
- const version = await probe(['--version'])
64
- if (version.err && version.err.code === 'ENOENT') return { installed: false, authenticated: false, user: null, message: 'Quilt can\'t list your GitHub repositories on this computer: start from a folder (a clone) instead.' }
65
- const out = await probe(['auth', 'status', '--hostname', 'github.com'])
66
- if (out.err) return { installed: true, authenticated: false, user: null, message: 'Quilt can\'t list your GitHub repositories on this computer: start from a folder (a clone) instead.' }
67
- const user = (out.text.match(/account (\S+)/) || out.text.match(/as (\S+)/) || [])[1] || null
68
- return { installed: true, authenticated: true, user, message: null }
69
- }
70
-
71
- /** Your repos, plus your organizations' repos, most recently updated first. */
72
- export async function listRepos ({ limit = 100 } = {}) {
73
- limit = Math.max(1, Math.min(1000, Number(limit) || 100))
74
- const fields = ['--json', 'nameWithOwner,description,updatedAt,defaultBranchRef']
75
- const parse = (out) => { try { return JSON.parse(out || '[]') } catch { return [] } }
76
- const mine = parse(await gh(['repo', 'list', ...fields, '--limit', String(limit)]))
77
- const orgs = String(await gh(['api', 'user/orgs', '--jq', '.[].login'], { allowFail: true }) || '').split('\n').filter(Boolean).slice(0, 10)
78
- const theirs = await Promise.all(orgs.map((o) => gh(['repo', 'list', o, ...fields, '--limit', String(Math.min(limit, 100))], { allowFail: true }).then(parse)))
79
- const seen = new Set()
80
- return [...mine, ...theirs.flat()]
81
- .filter((r) => r && r.nameWithOwner && !seen.has(r.nameWithOwner) && seen.add(r.nameWithOwner))
82
- .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
83
- .map((r) => ({ name: r.nameWithOwner, description: r.description || '', updatedAt: r.updatedAt || null, defaultBranch: r.defaultBranchRef?.name || null }))
84
- }
85
-
86
- /** Branch names of a GitHub repo, and which one is the default. */
87
- export async function listBranches (repo) {
88
- repo = checkRepo(repo)
89
- const [names, def] = await Promise.all([
90
- gh(['api', `repos/${repo}/branches`, '--paginate', '--jq', '.[].name']),
91
- gh(['api', `repos/${repo}`, '--jq', '.default_branch'])
92
- ])
93
- const defaultBranch = def.trim() || null
94
- const branches = names.split('\n').map((s) => s.trim()).filter(Boolean)
95
- // Default branch first, then alphabetical.
96
- branches.sort((a, b) => (a === defaultBranch ? -1 : b === defaultBranch ? 1 : a.localeCompare(b)))
97
- return { branches, defaultBranch }
98
- }
99
-
100
- async function defaultBranchOf (dir) {
101
- const head = await git(dir, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { allowFail: true })
102
- if (head && head.trim()) return head.trim().replace(/^origin\//, '')
103
- for (const b of ['main', 'master']) {
104
- if ((await git(dir, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${b}`], { allowFail: true })) !== null) return b
105
- }
106
- return null
107
- }
108
-
109
- /**
110
- * Clones `repo` into `dir` (which must be new or empty), then checks out
111
- * `branch`, or creates `newBranch` from origin/`base` (default: the default branch).
112
- */
113
- export async function cloneRepo ({ repo, dir, branch, newBranch, base }) {
114
- repo = checkRepo(repo)
115
- if (!dir) throw new Error('Choose a folder to clone into.')
116
- dir = path.resolve(dir)
117
- if (branch) branch = await checkBranchName(branch)
118
- if (newBranch) newBranch = await checkBranchName(newBranch)
119
- if (base) base = await checkBranchName(base)
120
- const existed = fs.existsSync(dir)
121
- if (existed && fs.readdirSync(dir).some((f) => f !== '.DS_Store')) {
122
- throw new Error(`${path.basename(dir)} already has files in it. Pick an empty or new folder.`)
123
- }
124
- fs.mkdirSync(path.dirname(dir), { recursive: true })
125
- try {
126
- await gh(['repo', 'clone', repo, dir])
127
- const has = async (b) => (await git(dir, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${b}`], { allowFail: true })) !== null
128
- if (newBranch) {
129
- base = base || await defaultBranchOf(dir)
130
- if (!base || !(await has(base))) throw new Error(`There's no branch named ${base} on GitHub to start from.`)
131
- if (await has(newBranch)) throw new Error(`A branch named ${newBranch} already exists. Use it instead, or pick another name.`)
132
- await git(dir, ['checkout', '-q', '--no-track', '-b', newBranch, `origin/${base}`])
133
- } else if (branch) {
134
- const current = (await git(dir, ['branch', '--show-current'])).trim()
135
- if (branch !== current) {
136
- if (!(await has(branch))) throw new Error(`There's no branch named ${branch} on GitHub.`)
137
- await git(dir, ['checkout', '-q', '-b', branch, '--track', `origin/${branch}`])
138
- }
139
- }
140
- return { dir, branch: (await git(dir, ['branch', '--show-current'])).trim() }
141
- } catch (err) {
142
- // Only clean up what we made.
143
- if (!existed) fs.rmSync(dir, { recursive: true, force: true })
144
- else for (const f of fs.readdirSync(dir)) if (f !== '.DS_Store') fs.rmSync(path.join(dir, f), { recursive: true, force: true })
145
- throw err
146
- }
147
- }
@@ -1 +0,0 @@
1
- The rules for this folder are in `seam/AGENTS.md`: seam is source control for more than Quilt.
@@ -1 +0,0 @@
1
- @../../seam/AGENTS.md