heyquilt 0.3.106 → 0.3.108

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.
package/RELEASES.md CHANGED
@@ -11,6 +11,14 @@ Format: `## <version> — <YYYY-MM-DD>`, an optional one-line summary, then bull
11
11
  Lead each bullet with a short bold phrase. Inline `code` and **bold** are rendered;
12
12
  nothing else is.
13
13
 
14
+ ## 0.3.108 — 2026-10-19
15
+
16
+ - **A git reset over a held file isn't taken as your change.** Moving your branch to a commit from elsewhere (a release made in another clone, say) with `git reset` leaves your files as they were. For a file someone else held, Quilt then measured your copy from the new commit, so it looked as if you had taken that commit's change out: a merge appeared under your name, and the change was briefly removed when they let go. Your copy is now measured from the commit it came from, so nothing is recorded, and the commit's change comes in once they let go.
17
+
18
+ ## 0.3.107 — 2026-10-19
19
+
20
+ - **Your cloud AI's sessions stay listed.** Settings → Your AI shows the sessions your cloud AI is in while its machine sleeps and after Quilt's servers restart, instead of "In no session yet". A session nobody named shows as your folder's name, as the rest of the app shows it.
21
+
14
22
  ## 0.3.106 — 2026-10-19
15
23
 
16
24
  0.3.105 didn't publish (GitHub didn't run its builds), so it comes with this one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyquilt",
3
- "version": "0.3.106",
3
+ "version": "0.3.108",
4
4
  "description": "Real-time, tool-agnostic pair vibe coding: sync a project folder live between collaborators using any AI coding tool.",
5
5
  "author": {
6
6
  "name": "Daniel Carmichael",
@@ -1013,7 +1013,7 @@ export function createMemoryStore ({ now = Date.now } = {}) {
1013
1013
  async createAiVm ({ userId, tool, model = '', prompt = '', cap = 0, region = '' }) {
1014
1014
  if (!profiles.has(userId)) throw fkViolation('person', 'does not exist')
1015
1015
  if (aiVms.has(userId)) throw duplicate('cloud AI machine')
1016
- const row = { userId, tool, model, prompt, cap, agentId: null, inviteId: null, machineId: null, volumeId: null, region, state: 'creating', error: '', signedIn: false, usageToday: {}, lastReportAt: null, createdAt: now(), updatedAt: now() }
1016
+ const row = { userId, tool, model, prompt, cap, agentId: null, inviteId: null, machineId: null, volumeId: null, region, state: 'creating', error: '', signedIn: false, usageToday: {}, sessions: [], lastReportAt: null, createdAt: now(), updatedAt: now() }
1017
1017
  aiVms.set(userId, row); return copy(row)
1018
1018
  },
1019
1019
  async aiVmOf (userId) { return copy(aiVms.get(userId)) },
@@ -69,14 +69,14 @@ export async function cloudAiOf (store, agent) {
69
69
  }
70
70
 
71
71
  export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log = () => {}, limit, relaySecret = '', relayUrl = HOSTED_RELAY, fly = null, waitMs = WAIT_MS, sparesEveryMs = SPARES_EVERY_MS, spareSuspendMs = 1000 }) {
72
- // user id -> { version, signIn, sessions, waiters, forVm: { follow, leave, signIn, code, config, waiters } }
72
+ // user id -> { version, signIn, waiters, forVm: { follow, leave, signIn, code, config, waiters } }
73
73
  const live = new Map()
74
74
  const ownRelay = String(relayUrl).replace(/\/+$/, '').replace(/^http(s?):\/\//, 'ws$1://')
75
75
  const relayWokeAt = new Map()
76
76
 
77
77
  const liveOf = (userId) => {
78
78
  let l = live.get(userId)
79
- if (!l) { l = { version: 1, signIn: null, sessions: [], waiters: new Set(), forVm: { follow: [], leave: [], signIn: false, code: null, config: false, waiters: new Set() } }; live.set(userId, l) }
79
+ if (!l) { l = { version: 1, signIn: null, waiters: new Set(), forVm: { follow: [], leave: [], signIn: false, code: null, config: false, waiters: new Set() } }; live.set(userId, l) }
80
80
  return l
81
81
  }
82
82
  const wakeWaiters = (set) => { for (const w of [...set]) w(); set.clear() }
@@ -210,7 +210,8 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
210
210
  error: vm.error,
211
211
  signedIn: vm.signedIn,
212
212
  signIn: l.signIn,
213
- sessions: l.sessions,
213
+ // Kept with the record: shown while the machine sleeps, and after the API restarts.
214
+ sessions: Array.isArray(vm.sessions) ? vm.sessions : [],
214
215
  usageToday: vm.usageToday || {},
215
216
  region: vm.region,
216
217
  lastReportAt: vm.lastReportAt,
@@ -421,7 +422,7 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
421
422
  if (typeof body.signedIn === 'boolean') patch.signedIn = body.signedIn
422
423
  if (body.usageToday !== undefined) patch.usageToday = cleanUsage(body.usageToday)
423
424
  if (body.signIn !== undefined) l.signIn = cleanSignIn(body.signIn)
424
- if (body.sessions !== undefined) l.sessions = cleanSessions(body.sessions)
425
+ if (body.sessions !== undefined) patch.sessions = cleanSessions(body.sessions)
425
426
  await store.updateAiVm(vm.userId, patch)
426
427
  bump(vm.userId)
427
428
  return { ok: true }
@@ -38,7 +38,7 @@ const AGENT_PLACEMENT = 'agent_id, reach, workspace_ids, sessions, access, scope
38
38
  const WORKSPACE_AGENT_OVERRIDE = 'workspace_id, agent_id, sessions, excluded'
39
39
  const SESSION_AGENT_EXCLUSION = 'room, agent_id, excluded_by, created_at'
40
40
  const AGENT_WEBHOOK = 'agent_id, url, secret, created_at, updated_at'
41
- const AI_VM = 'user_id, tool, model, prompt, cap, agent_id, invite_id, machine_id, volume_id, region, state, error, signed_in, usage_today, last_report_at, created_at, updated_at'
41
+ const AI_VM = 'user_id, tool, model, prompt, cap, agent_id, invite_id, machine_id, volume_id, region, state, error, signed_in, usage_today, sessions, last_report_at, created_at, updated_at'
42
42
  const RELAY_MACHINE = 'id, region, capacity, state, dedicated_room, load, hot_at, over_at, empty_at, hello_at, reported_at, created_at'
43
43
  const RELAY_ROOM = 'room, machine_id, state, stats, busy_minutes, heavy, placed_at, reported_at, moved_at'
44
44
  const RELAY_JOB = 'key, state, detail, started_at, finished_at'
package/src/session.js CHANGED
@@ -2892,7 +2892,7 @@ export class Session extends EventEmitter {
2892
2892
  if (changed.length && this.followsRelay && this.branchLive()) {
2893
2893
  const atHead = await filesAt(this.root, head.sha, changed)
2894
2894
  const has = () => atHead && changed.every((rel) => this.sharedKey(rel) === (atHead.get(rel) ?? undefined))
2895
- if (atHead && !atHead.failed && (has() || await this.relayHasPull(head.sha, has))) old = atHead
2895
+ if (atHead && !atHead.failed && (has() || await this.relayHasPull(head.sha, has))) old = this.pulledBases(changed, old, atHead)
2896
2896
  }
2897
2897
  if (old.failed) this.log(`⚠️ git could not read ${old.failed} file${old.failed === 1 ? '' : 's'} at the old commit; merged without a base, so where they differ the newer version stays`)
2898
2898
  // A file git made the person resolve (stash pop, rebase) holds their merge of the commits and the
@@ -2927,6 +2927,31 @@ export class Session extends EventEmitter {
2927
2927
  }
2928
2928
  }
2929
2929
 
2930
+ /**
2931
+ * The base of each pulled path once the relay is at the pulled commit (planSettle): the file at that commit,
2932
+ * so the pull's change isn't shared again on top of the relay's. Not for a file the session doesn't have at
2933
+ * that commit (the relay holds GitHub's change while someone holds the file) whose disk doesn't have the
2934
+ * pull's change either (git moved the branch and left the file, as `git reset` does): measured from the
2935
+ * commit, the disk would look like it took the pull's change away. That one is measured from the old commit.
2936
+ */
2937
+ pulledBases (rels, old, atHead) {
2938
+ const out = new Map(atHead)
2939
+ out.failed = 0
2940
+ const text = (k) => typeof k === 'string' && !k.startsWith('bin:')
2941
+ for (const rel of rels) {
2942
+ const pulled = atHead.get(rel) ?? undefined
2943
+ const was = old.get(rel) ?? undefined
2944
+ if (pulled === was || this.sharedKey(rel) === pulled) continue
2945
+ const disk = this.readDisk(rel)
2946
+ const ours = disk && !disk.skip && !disk.tooLarge ? disk.key : null
2947
+ if (ours === pulled) continue
2948
+ // The disk has the pull's change when combining the pull into it changes nothing.
2949
+ const hasPull = text(ours) && text(was) && text(pulled) && (() => { const m = merge3(was, ours, pulled); return !m.conflicts.length && m.text === ours })()
2950
+ if (!hasPull) out.set(rel, old.get(rel))
2951
+ }
2952
+ return out
2953
+ }
2954
+
2930
2955
  onDisk (rel) {
2931
2956
  try { fs.lstatSync(path.join(this.root, ...rel.split('/'))); return true } catch { return false }
2932
2957
  }
package/src/ui-server.js CHANGED
@@ -555,7 +555,10 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
555
555
  if (!vm) { handed.clear(); return { vm: null } }
556
556
  followOpen(vm)
557
557
  const person = readAccount()?.account?.name || profile().name || 'You'
558
- return { vm: { ...vm, name: personaName(person, CLOUD_LABEL) } }
558
+ // A session nobody named is shown as the app shows it: by this computer's folder for it.
559
+ const local = new Map([...runs.values()].filter((e) => e.run && e.run.session).map((e) => [e.run.session.room, e.run.session.sessionName || path.basename(e.run.dir || '')]))
560
+ const sessions = (vm.sessions || []).map((x) => (x.name ? x : { ...x, name: local.get(x.room) || '' }))
561
+ return { vm: { ...vm, sessions, name: personaName(person, CLOUD_LABEL) } }
559
562
  }
560
563
  const cloudNone = (err) => { if (err.status === 404) return cloudView(null); throw err }
561
564
  /** What Settings may change: the tool, model, instructions and daily cap (blank is no cap). */