heyquilt 0.3.116 → 0.3.118

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,8 +11,25 @@ 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.118 — 2026-10-25
15
+
16
+ - **A new name for your AI shows at once.** Renaming your cloud AI (or changing its color) now makes it join its sessions again under the new name within seconds, once it isn't in the middle of a run, so @its new name reaches it. Before, it kept its old name for up to five minutes and then dropped out of the session's people list.
17
+ - **Your AI answers while it works on a task.** A message to your cloud AI no longer waits behind a task it is working on (up to half an hour): it answers right away, changing no files while the task runs, and if you ask for more work it says it will do it next or adds it to the board.
18
+ 0.3.117 did not finish publishing, so this release carries it.
19
+
20
+ - **Your AI is no longer held back when it finishes.** The AI sessions on your computer (Claude Code, Cursor, Gemini CLI) no longer get Quilt's hook as they finish a turn, which could send them back to work with messages that were for you. Quilt takes out the one it wrote earlier the next time a session starts. Files they edited are let go when their session ends, or after 20 quiet minutes. Agents that joined as their own members, your cloud AI among them, keep it: they still answer what is written to them before they stop.
21
+
22
+ ## 0.3.117 — 2026-10-25
23
+
24
+ - **Your AI is no longer held back when it finishes.** The AI sessions on your computer (Claude Code, Cursor, Gemini CLI) no longer get Quilt's hook as they finish a turn, which could send them back to work with messages that were for you. Quilt takes out the one it wrote earlier the next time a session starts. Files they edited are let go when their session ends, or after 20 quiet minutes. Agents that joined as their own members, your cloud AI among them, keep it: they still answer what is written to them before they stop.
25
+
14
26
  ## 0.3.116 — 2026-10-25
15
27
 
28
+ - **Set up your AI, step by step.** Settings → Agents invites you to give your AI a seat in your sessions; setting it up takes over Settings (the pop-up from a session, or the Settings page) as five steps: its name and color, which AI it runs on (Claude Code, Codex or Cursor, the model and a daily limit), what it may do, which sessions it works in, then your tool's own sign-in. Its card fills in as you answer, and you can change every answer later from its card.
29
+ - **Your AI can have its own name.** Call it Juniper, Atlas or anything you like: people see it as Juniper with a "Daniel's AI" tag, and both @Juniper and @Daniel's AI reach it. Messages to it stay yours to read, also while it sleeps.
30
+ - **Decide what your AI does.** It always answers people who write to it. You choose whether it picks up tasks (only when you ask, the ones assigned to it, or any open task, one after another), whether it changes files and pushes branches of its own, and whether every message wakes its machine (off: messages wait for its next wake, for a task or when you work in one of its sessions). House rules go with every run.
31
+ - **Choose where your AI works.** Every session you join, only the ones you pick, or none for now; picking fewer makes it leave the others.
32
+ - **Settings: the AI on this computer.** Its card no longer has "pick up tasks by itself": the AI on your computer works on what you ask it, and tasks for your AI are your cloud AI's.
16
33
  - **Source control has its own command, `seam`.** Quilt Source's git verbs (`seam log`, `seam merge`, `seam checkout`…) now come from their own package, `@heyquilt/seam`, with their own sign-in (`seam login`) and their own MCP server (`seam mcp`, tools named `seam_*`). The Quilt app's `quilt` command and Quilt's MCP no longer carry source control: Quilt sessions and Quilt Source are separate products. Block folders now hold `.seam/block.json`, and agents give seam their key with `SEAM_TOKEN`. The docs at frame.heyquilt.com say the same.
17
34
 
18
35
  ## 0.3.115 — 2026-10-25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyquilt",
3
- "version": "0.3.116",
3
+ "version": "0.3.118",
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",
package/src/account.js CHANGED
@@ -580,10 +580,10 @@ export async function aiVmStatus ({ token, wait, api = apiUrl(), fetch: fetchImp
580
580
  }
581
581
 
582
582
  /** Connects a cloud AI running `tool` (Claude Code, Codex or Cursor): starts its machine. */
583
- export async function aiVmConnect ({ token, tool, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
584
- return call(fetchImpl, api, 'POST', '/v1/me/ai-vm', { tool }, token)
583
+ export async function aiVmConnect ({ token, tool, setup = {}, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
584
+ return call(fetchImpl, api, 'POST', '/v1/me/ai-vm', { ...setup, tool }, token)
585
585
  }
586
- /** Changes the cloud AI's settings: `patch` is any of { tool, model, prompt, cap }. */
586
+ /** Changes the cloud AI's settings: `patch` is any of { tool, model, prompt, cap, name, color, tasks, files, wake, sessionsMode, rooms }. */
587
587
  export async function aiVmUpdate ({ token, patch, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
588
588
  return call(fetchImpl, api, 'PATCH', '/v1/me/ai-vm', patch, token)
589
589
  }
package/src/ai-vm.js CHANGED
@@ -352,6 +352,9 @@ export class AiVm {
352
352
  this.apiOkAt = this.now()
353
353
  if (this.stopped) return n
354
354
  const changedTool = !this.config || this.config.tool !== n.config.tool
355
+ // A new name or color (its person changed its setup): it joins its sessions again as that, once
356
+ // nothing runs. A connection keeps the name it joined with; the relay drops a presence under another.
357
+ if (this.config && (this.config.name !== n.config.name || this.config.color !== n.config.color)) this.reidentify = true
355
358
  this.applyConfig(n.config)
356
359
  const busy = n.signIn || n.code || n.follow.length || n.leave.length
357
360
  if (busy) this.lastActivity = this.now()
@@ -366,13 +369,32 @@ export class AiVm {
366
369
  for (const link of n.follow) await this.follow(link).catch((err) => this.log(`could not join: ${err.message}`))
367
370
  for (const room of n.leave) await this.leave(room).catch((err) => this.log(`could not leave ${room}: ${err.message}`))
368
371
  if (busy || changedTool) await this.report().catch(() => {})
372
+ await this.reidentifyIfIdle()
369
373
  return n
370
374
  }
371
375
 
376
+ /** Joins every session again under its new name and color, when it has one and nothing runs. */
377
+ async reidentifyIfIdle () {
378
+ if (!this.reidentify || this.stopped || await this.busy()) return []
379
+ this.reidentify = false
380
+ const again = []
381
+ for (const [room, link] of Object.entries(this.followed())) {
382
+ const r = this.runs.get(room)
383
+ if (!r) continue
384
+ this.log(`[${room}] joining again as ${(this.config && this.config.name) || 'its new self'}`)
385
+ this.runs.delete(room)
386
+ await r.run.stop().catch(() => {})
387
+ try { await this.follow(link); again.push(room) } catch (err) { this.log(`could not rejoin ${room}: ${err.message}`) }
388
+ }
389
+ return again
390
+ }
391
+
372
392
  /** The person's settings, as this machine's own "Work on its own" settings. */
373
393
  applyConfig (c) {
374
394
  this.config = c
375
- saveSettings({ tool: c.tool, autoWork: 'auto', autoFiles: true, autoModel: c.model || undefined, autoCap: c.cap || undefined, autoPrompt: c.prompt || undefined })
395
+ // What its person chose it does: which tasks it takes by itself, and whether it changes files.
396
+ const tasks = { asked: 'off', mine: 'mine', any: 'any' }[c.tasks] || 'mine'
397
+ saveSettings({ tool: c.tool, autoWork: 'auto', autoFiles: c.files !== false, aiTasks: tasks, autoModel: c.model || undefined, autoCap: c.cap || undefined, autoPrompt: c.prompt || undefined })
376
398
  }
377
399
 
378
400
  // ------------------------------------------------------------- sign-in --
@@ -500,6 +522,7 @@ export class AiVm {
500
522
  conn: { server: invite.relay || relayUrl(), room, secret: invite.secret },
501
523
  name: auth.name,
502
524
  tool: (this.config && this.config.tool) || 'Quilt cloud AI',
525
+ ...(this.config && this.config.color ? { color: this.config.color } : {}),
503
526
  kind: 'agent',
504
527
  passes: auth.passes,
505
528
  identity: auth.identity,
@@ -527,6 +550,7 @@ export class AiVm {
527
550
  */
528
551
  async rejoinCheck () {
529
552
  if (this.stopped) return []
553
+ await this.reidentifyIfIdle()
530
554
  const now = this.now()
531
555
  const apiUp = this.apiOkAt && now - this.apiOkAt < this.rejoinMs
532
556
  const again = []
@@ -579,7 +603,7 @@ export class AiVm {
579
603
  async busy () {
580
604
  if (this.signing) return true
581
605
  const work = await this.autowork()
582
- return !!(work.running || (work.queue && work.queue.length))
606
+ return !!(work.running || work.talking || (work.queue && work.queue.length) || (work.talkQueue && work.talkQueue.length))
583
607
  }
584
608
 
585
609
  /**
@@ -1090,10 +1090,10 @@ export function createMemoryStore ({ now = Date.now } = {}) {
1090
1090
  async deleteAgentWebhook (agentId) { return agentWebhooks.delete(agentId) },
1091
1091
 
1092
1092
  // A person's cloud AI machine (see 20261017000000_ai_vms.sql): one per person.
1093
- async createAiVm ({ userId, tool, model = '', prompt = '', cap = 0, region = '' }) {
1093
+ async createAiVm ({ userId, tool, model = '', prompt = '', cap = 0, region = '', name = '', color = '', tasks = 'mine', files = true, wake = true, sessionsMode = 'every', rooms = [] }) {
1094
1094
  if (!profiles.has(userId)) throw fkViolation('person', 'does not exist')
1095
1095
  if (aiVms.has(userId)) throw duplicate('cloud AI machine')
1096
- 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() }
1096
+ const row = { userId, tool, model, prompt, cap, name, color, tasks, files, wake, sessionsMode, rooms: structuredClone(rooms), agentId: null, inviteId: null, machineId: null, volumeId: null, region, state: 'creating', error: '', signedIn: false, usageToday: {}, sessions: [], lastReportAt: null, createdAt: now(), updatedAt: now() }
1097
1097
  aiVms.set(userId, row); return copy(row)
1098
1098
  },
1099
1099
  async aiVmOf (userId) { return copy(aiVms.get(userId)) },
@@ -15,6 +15,14 @@ import { isHostedRelay, HOSTED_RELAY } from '../../settings.js'
15
15
  import { autoToolFor } from '../../autowork-tools.js'
16
16
 
17
17
  export const AI_VM_TOOLS = ['Claude Code', 'Codex', 'Cursor']
18
+ // Which tasks it takes by itself: only when asked, those assigned to it, or any open one.
19
+ export const AI_VM_TASKS = ['asked', 'mine', 'any']
20
+ // Which of its person's sessions it joins: every one they join, the rooms picked, or none.
21
+ export const AI_VM_SESSIONS = ['every', 'pick', 'none']
22
+ // Its own name: letters, digits, spaces and . ' - (1 to 40); blank is "<person>'s AI".
23
+ const AI_NAME = /^[\p{L}\p{N}][\p{L}\p{N} .'’-]{0,39}$/u
24
+ const COLOR = /^#[0-9a-fA-F]{6}$/
25
+ const RESERVED_NAMES = ['quilt', 'agents', 'everyone', 'the relay']
18
26
  // The machine reports about once a minute; one not heard from for this long may have stopped.
19
27
  const QUIET_MS = 3 * 60 * 1000
20
28
  // How long a waiting GET is held before it answers with nothing new.
@@ -191,6 +199,7 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
191
199
  }
192
200
 
193
201
  const needFly = () => { if (!fly) throw new HttpError(503, "Cloud AI isn't set up on this server.") }
202
+ const personName = async (userId) => { const p = await store.profile(userId); return String((p && p.name) || '') }
194
203
  async function mine (req) {
195
204
  const u = await person(req)
196
205
  const vm = await store.aiVmOf(u.userId)
@@ -203,6 +212,14 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
203
212
  const l = liveOf(vm.userId)
204
213
  return {
205
214
  tool: vm.tool,
215
+ // Its own name and color ('' : "<person>'s AI", a color of its own), and what it does.
216
+ aiName: vm.name || '',
217
+ color: vm.color || '',
218
+ tasks: vm.tasks || 'mine',
219
+ files: vm.files !== false,
220
+ wake: vm.wake !== false,
221
+ sessionsMode: vm.sessionsMode || 'every',
222
+ rooms: Array.isArray(vm.rooms) ? vm.rooms : [],
206
223
  model: vm.model,
207
224
  models: autoToolFor(vm.tool)?.models || [],
208
225
  prompt: vm.prompt,
@@ -270,8 +287,44 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
270
287
  // `config`: the settings changed since the machine last asked (a new tool signs in at once).
271
288
  const pendingFor = (f) => f.follow.length || f.leave.length || f.signIn || f.code !== null || f.config
272
289
 
273
- function cleanSettings (b, vm) {
290
+ /** Whether the person's cloud AI joins `room`, by the sessions they chose for it. */
291
+ const joins = (vm, room) => {
292
+ const mode = vm.sessionsMode || 'every'
293
+ return mode === 'every' || (mode === 'pick' && (vm.rooms || []).includes(room))
294
+ }
295
+
296
+ /** `vm`: the record as it is ({} for a new one); `person`: their name, which the AI's may not be. */
297
+ function cleanSettings (b, vm, person = '') {
274
298
  const out = {}
299
+ if (b.name !== undefined) {
300
+ const name = String(b.name).trim().replace(/\s+/g, ' ')
301
+ if (name && !AI_NAME.test(name)) throw new HttpError(400, "Your AI's name: up to 40 letters, numbers and spaces.")
302
+ const low = name.toLowerCase()
303
+ if (name && (RESERVED_NAMES.includes(low) || low === String(person).trim().toLowerCase())) throw new HttpError(400, `"${name}" is taken: pick another name for your AI.`)
304
+ // "<person>'s AI" is the name it has anyway: kept blank, so it follows a new name of theirs.
305
+ out.name = person && name === `${String(person).trim()}'s AI` ? '' : name
306
+ }
307
+ if (b.color !== undefined) {
308
+ if (b.color !== '' && !COLOR.test(String(b.color))) throw new HttpError(400, 'color: #rrggbb')
309
+ out.color = String(b.color)
310
+ }
311
+ if (b.tasks !== undefined) {
312
+ if (!AI_VM_TASKS.includes(b.tasks)) throw new HttpError(400, `tasks is one of ${AI_VM_TASKS.join(', ')}`)
313
+ out.tasks = b.tasks
314
+ }
315
+ for (const k of ['files', 'wake']) {
316
+ if (b[k] === undefined) continue
317
+ if (typeof b[k] !== 'boolean') throw new HttpError(400, `${k}: true or false`)
318
+ out[k] = b[k]
319
+ }
320
+ if (b.sessionsMode !== undefined) {
321
+ if (!AI_VM_SESSIONS.includes(b.sessionsMode)) throw new HttpError(400, `sessionsMode is one of ${AI_VM_SESSIONS.join(', ')}`)
322
+ out.sessionsMode = b.sessionsMode
323
+ }
324
+ if (b.rooms !== undefined) {
325
+ if (!Array.isArray(b.rooms) || b.rooms.length > 200 || !b.rooms.every((r) => ROOM.test(String(r)))) throw new HttpError(400, 'rooms: session names')
326
+ out.rooms = [...new Set(b.rooms.map(String))]
327
+ }
275
328
  if (b.tool !== undefined) {
276
329
  if (!AI_VM_TOOLS.includes(b.tool)) throw new HttpError(400, `tool is one of ${AI_VM_TOOLS.join(', ')}`)
277
330
  // A new tool signs in again and starts from its own models.
@@ -312,10 +365,12 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
312
365
  limit(req)
313
366
  if (!AI_VM_TOOLS.includes(body.tool)) throw new HttpError(400, `tool is one of ${AI_VM_TOOLS.join(', ')}`)
314
367
  if (await store.aiVmOf(u.userId)) throw new HttpError(409, 'You already have a cloud AI. Disconnect it first.')
368
+ // What the person chose while setting it up: its name, color, model, rules, what it does, where.
369
+ const { tool: _tool, signedIn: _s, ...setup } = cleanSettings(body, { tool: body.tool }, await personName(u.userId))
315
370
  // The machine joins as the person's agent with a one-time link, so its keys are made there.
316
371
  const token = newToken('qj_')
317
372
  const invite = await store.createAgentInvite({ tokenHash: hashToken(token), ownerUserId: u.userId, createdBy: u.userId, expiresAt: now() + AGENT_INVITE_TTL_MS })
318
- let vm = await store.createAiVm({ userId: u.userId, tool: body.tool, region: fly.region })
373
+ let vm = await store.createAiVm({ ...setup, userId: u.userId, tool: body.tool, region: fly.region })
319
374
  vm = await store.updateAiVm(u.userId, { inviteId: invite.id })
320
375
  try {
321
376
  const m = await machineFor(u.userId, { QUILT_API: apiUrl, QUILT_AI_VM_JOIN: `${apiUrl}/v1/join/${token}`, HOME: '/data/home' })
@@ -333,9 +388,15 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
333
388
 
334
389
  ['PATCH', /^\/v1\/me\/ai-vm$/, async (req, body) => {
335
390
  const { u, vm } = await mine(req)
336
- const patch = cleanSettings(body, vm)
391
+ const patch = cleanSettings(body, vm, await personName(u.userId))
337
392
  if (!Object.keys(patch).length) return view(vm)
338
393
  let up = await store.updateAiVm(u.userId, patch)
394
+ // Sessions it may no longer be in: it leaves them (it joins the newly picked ones as the app hands them over).
395
+ if (patch.sessionsMode !== undefined || patch.rooms !== undefined) {
396
+ const f = liveOf(u.userId).forVm
397
+ for (const x of up.sessions || []) if (!joins(up, x.room) && !f.leave.includes(x.room)) f.leave = [...f.leave, x.room].slice(-MAX_PENDING)
398
+ f.follow = f.follow.filter((l) => joins(up, parseInvite(l, { allowRelay: () => true }).room))
399
+ }
339
400
  liveOf(u.userId).forVm.config = true
340
401
  // A new tool's sign-in starts over: the old tool's link and code are not shown for it.
341
402
  if (patch.tool) liveOf(u.userId).signIn = { step: 'starting', url: '', code: '', needsCode: false, error: '' }
@@ -377,6 +438,8 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
377
438
  let invite
378
439
  try { invite = parseInvite(String(body.link || ''), { allowRelay: (s) => isHostedRelay(s) || String(s).replace(/\/+$/, '') === ownRelay }) } catch { throw new HttpError(400, "link must be a Quilt session's invite link") }
379
440
  if (!invite.secret) throw new HttpError(400, "link must be a Quilt session's invite link")
441
+ // A session its person didn't choose for it: not joined (its app hands over every session).
442
+ if (!joins(vm, invite.room)) return { ok: true, joins: false }
380
443
  const link = buildInvite({ server: invite.relay || HOSTED_RELAY, room: invite.room, secret: invite.secret }, isHostedRelay)
381
444
  f.leave = f.leave.filter((r) => r !== invite.room)
382
445
  f.follow = [...f.follow.filter((l) => parseInvite(l, { allowRelay: () => true }).room !== invite.room), link].slice(-MAX_PENDING)
@@ -411,7 +474,7 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
411
474
  const fresh = (await store.aiVmOf(vm.userId)) || vm
412
475
  const p = await store.profile(vm.userId)
413
476
  const out = {
414
- config: { tool: fresh.tool, model: fresh.model, prompt: fresh.prompt, cap: fresh.cap, person: (p && p.name) || 'Quilt user' },
477
+ config: { tool: fresh.tool, model: fresh.model, prompt: fresh.prompt, cap: fresh.cap, person: (p && p.name) || 'Quilt user', name: fresh.name || '', color: fresh.color || '', tasks: fresh.tasks || 'mine', files: fresh.files !== false },
415
478
  follow: f.follow,
416
479
  leave: f.leave,
417
480
  signIn: f.signIn,
@@ -491,6 +554,9 @@ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log
491
554
  const agent = UUID.test(agentId) ? await store.agentById(agentId) : null
492
555
  const vm = agent && !agent.revokedAt ? await cloudAiOf(store, agent) : null
493
556
  if (!vm) throw new HttpError(404, 'no such cloud AI')
557
+ // Its person turned "Wake for every message" off: a message waits for its next wake (a task,
558
+ // or its person at work in the session). An older relay says no `why`: it wakes, as before.
559
+ if (vm.wake === false && body.why === 'message') return { ok: true, asleep: true }
494
560
  const last = relayWokeAt.get(agentId) || 0
495
561
  if (now() - last < RELAY_WAKE_MS) return { ok: true, already: true }
496
562
  relayWokeAt.set(agentId, now())
package/src/api/server.js CHANGED
@@ -152,9 +152,9 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
152
152
  }
153
153
 
154
154
  /**
155
- * Who a pass for an agent is for. A person's cloud AI (routes/ai-vm.js) is that
156
- * person's AI: named "<person>'s AI" ("Dan's AI"), with its person's account (`of`) and name
157
- * (`ofName`), which the relay lets in where its person stands.
155
+ * Who a pass for an agent is for. A person's cloud AI (routes/ai-vm.js) is that person's AI:
156
+ * named what they called it ("Juniper"), or "<person>'s AI" ("Dan's AI"), with its person's
157
+ * account (`of`) and name (`ofName`), which the relay lets in where its person stands.
158
158
  */
159
159
  async function agentHolder (agent) {
160
160
  const holder = { sub: agent.id, kind: 'agent', name: agent.name.slice(0, 64), key: agent.publicKey || '' }
@@ -162,7 +162,7 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
162
162
  if (!vm) return holder
163
163
  const p = await store.profile(vm.userId)
164
164
  const person = String((p && p.name) || 'Quilt user').slice(0, 64)
165
- return { ...holder, name: aiName(person.slice(0, 58)), of: `person:${vm.userId}`, ofName: person }
165
+ return { ...holder, name: vm.name || aiName(person.slice(0, 58)), of: `person:${vm.userId}`, ofName: person }
166
166
  }
167
167
 
168
168
  // Resume signatures seen in the last window, so a copied one can't be used again: here, and in
@@ -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, sessions, last_report_at, created_at, updated_at'
41
+ const AI_VM = 'user_id, tool, model, prompt, cap, name, color, tasks, files, wake, sessions_mode, rooms, 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'
@@ -564,8 +564,8 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
564
564
  },
565
565
 
566
566
  // A person's cloud AI machine (see 20261017000000_ai_vms.sql): one per person.
567
- async createAiVm ({ userId, tool, model = '', prompt = '', cap = 0, region = '' }) {
568
- return rowFrom(await one(db.from('ai_vms').insert({ user_id: userId, tool, model, prompt, cap, region }).select(AI_VM).single()))
567
+ async createAiVm ({ userId, tool, model = '', prompt = '', cap = 0, region = '', ...setup }) {
568
+ return rowFrom(await one(db.from('ai_vms').insert({ user_id: userId, tool, model, prompt, cap, region, ...toSnake(setup) }).select(AI_VM).single()))
569
569
  },
570
570
  async aiVmOf (userId) { return rowFrom(await one(db.from('ai_vms').select(AI_VM).eq('user_id', userId).maybeSingle())) },
571
571
  async aiVmByAgent (agentId) { return agentId ? rowFrom(await one(db.from('ai_vms').select(AI_VM).eq('agent_id', agentId).maybeSingle())) : null },
package/src/autowork.js CHANGED
@@ -13,6 +13,7 @@ import { spawn } from 'node:child_process'
13
13
  import { getSettings } from './settings.js'
14
14
  import { quiltHome } from './legacy.js'
15
15
  import { autoToolFor, findExe } from './autowork-tools.js'
16
+ import { nextTask } from './tasks.js'
16
17
  import { claudeCli, installedEditors, openIn } from './editors.js'
17
18
 
18
19
  export const AUTO_MODES = ['chat', 'open', 'auto']
@@ -67,7 +68,7 @@ export function tokensOn (entries, ts) {
67
68
  * machine's caveman mode (CAVEMAN_DEFAULT_MODE on a cloud AI machine, vm/Dockerfile), said
68
69
  * here too so every tool talks that way, not only those whose own hooks turn it on.
69
70
  */
70
- export function runPrompt ({ person, job, changeFiles, prompt = '', voice = process.env.CAVEMAN_DEFAULT_MODE || '' }) {
71
+ export function runPrompt ({ person, job, changeFiles, prompt = '', voice = process.env.CAVEMAN_DEFAULT_MODE || '', busyWith = '' }) {
71
72
  const lines = [
72
73
  `You are ${person}'s AI in the Quilt session in this folder, working on your own: nobody is watching this chat, and it isn't a conversation.`,
73
74
  ''
@@ -85,7 +86,8 @@ export function runPrompt ({ person, job, changeFiles, prompt = '', voice = proc
85
86
  if (prompt) lines.push('', `${person}'s own instructions for you:`, prompt)
86
87
  if (/^[a-z]+$/.test(voice) && voice !== 'off') lines.push('', `Talk in ${voice} mode (the caveman skill): terse, like a smart caveman, in chat, comments and notes. Code, commands, paths, commit messages and error text stay exact.`)
87
88
  lines.push('', 'Before your first change, read the project\'s rules with quilt_project_rules.')
88
- if (!changeFiles) lines.push(`${person} hasn't let you change files: read, plan, comment on the task (quilt_comment_task) or ask, and leave the changes to a person or another session.`)
89
+ if (busyWith) lines.push(`Another run of yours is working on "${busyWith}" in this folder right now, so change no files here: answer now, and if they ask for work, say you'll do it once that is done, or add it to the board with quilt_add_task.`)
90
+ else if (!changeFiles) lines.push(`${person} hasn't let you change files: read, plan, comment on the task (quilt_comment_task) or ask, and leave the changes to a person or another session.`)
89
91
  lines.push(
90
92
  'If you need an answer from someone, ask them with quilt_message and stop: when they reply, this chat continues with their answer.',
91
93
  'When you finish, or stop to wait, end with a sentence or two on where things stand.'
@@ -137,8 +139,14 @@ export class AutoWork {
137
139
  this.now = now
138
140
  this.queue = []
139
141
  this.running = null
142
+ // Messages have a lane of their own, so a long task doesn't keep people waiting for an answer:
143
+ // one run there at a time too, changing no files while a task runs (two runs, one folder).
144
+ this.talkQueue = []
145
+ this.talking = null
146
+ this.talkIdle = Promise.resolve()
140
147
  this.idle = Promise.resolve()
141
148
  this.sessions = new Set()
149
+ this.tookTasks = new Set() // '<room> <task>' taken from the board by itself (takeNextTask)
142
150
  this.chats = this.loadChats()
143
151
  this.toldCap = ''
144
152
  }
@@ -179,6 +187,9 @@ export class AutoWork {
179
187
  // Their cloud AI, when it is in the session, takes it instead of this computer.
180
188
  // A task marked "not for auto pickup" waits for a person to start it, even given to the AI.
181
189
  if (e.kind === 'task' && e.task?.noPickup) continue
190
+ // A cloud AI its person set to take tasks only when asked leaves a task handed to it on the board
191
+ // (a machine set up before that choice existed has none saved: it takes them, as before).
192
+ if (e.kind === 'task' && session.aiOf && this.settings().aiTasks === 'off') continue
182
193
  if (e.kind === 'task' && session.forMyAi(e) && !session.leadPersona() && !this.leaveToCloud(session)) {
183
194
  if (this.queue.some((j) => j.kind === 'task' && j.id === e.task.id) || (this.running && this.running.job.id === e.task.id)) continue
184
195
  this.enqueue({ kind: 'task', session, id: e.task.id, title: e.task.title, by: e.by })
@@ -192,10 +203,12 @@ export class AutoWork {
192
203
  }
193
204
  // A person's cloud AI answers what is written to it (its inbox only has it while no AI session of theirs is live).
194
205
  if (session.aiOf && (e.kind === 'dm' || e.kind === 'mention') && !e.queue && !e.commit && !e.nudge && session.forMyAi(e)) {
195
- if (this.queue.some((j) => j.kind === 'message' && j.id === e.id)) continue
206
+ if ([...this.queue, ...this.talkQueue].some((j) => j.kind === 'message' && j.id === e.id) || (this.talking && this.talking.job.id === e.id)) continue
196
207
  this.enqueue({ kind: 'message', session, id: e.id, title: `message from ${e.by}`, by: e.by, text: e.text })
197
208
  }
198
209
  }
210
+ // Idle, and allowed any open task: the board's next one.
211
+ if (!this.running) this.takeNextTask(session)
199
212
  }
200
213
 
201
214
  /** Whether work for the person's AI is their cloud AI's, not this computer's: it is in the session, or they have one. */
@@ -226,20 +239,55 @@ export class AutoWork {
226
239
  }
227
240
 
228
241
  enqueue (job) {
242
+ // A message, or a reply to one, is talk; a reply to a task's run carries that run on, in its lane.
243
+ if (job.kind === 'message' || (job.kind === 'reply' && job.run && job.run.kind === 'message')) {
244
+ this.talkQueue.push(job)
245
+ this.talkIdle = this.talkIdle.then(() => this.nextTalk()).catch(() => {})
246
+ return this.talkIdle
247
+ }
229
248
  this.queue.push(job)
230
249
  this.idle = this.idle.then(() => this.next()).catch(() => {})
231
250
  return this.idle
232
251
  }
233
252
 
234
253
  async next () {
254
+ // An answer that may change files (no task was running when it started) finishes first.
255
+ while (this.talking && !this.talking.readOnly) await this.talking.done
235
256
  const job = this.queue.shift()
236
257
  if (!job) return
237
258
  this.running = { job }
238
259
  try { await this.run(job) } finally { this.running = null }
260
+ this.takeNextTask(job.session)
261
+ }
262
+
263
+ async nextTalk () {
264
+ const job = this.talkQueue.shift()
265
+ if (!job) return
266
+ // While a task runs in this folder, the answer changes no files.
267
+ const busyWith = this.running && this.running.job.session === job.session ? (this.running.job.title || 'a task') : ''
268
+ let finished
269
+ this.talking = { job, readOnly: !!busyWith, done: new Promise((resolve) => { finished = resolve }) }
270
+ try { await this.run(job, { busyWith }) } finally { this.talking = null; finished() }
271
+ }
272
+
273
+ /**
274
+ * A cloud AI that may take any open task: with nothing else to do, the next one on the board
275
+ * (assigned to it first, then unassigned; tasks.js nextTask picks it, as for every AI).
276
+ */
277
+ takeNextTask (session) {
278
+ if (!session || !session.aiOf || this.queue.length || !this.on()) return
279
+ if (this.settings().aiTasks !== 'any') return
280
+ let next = null
281
+ try { next = nextTask(session.taskList().filter((t) => !this.tookTasks.has(`${session.room} ${t.id}`)), { name: session.name, asAi: false }, 'any') } catch { return }
282
+ if (!next) return
283
+ // Each once: a run that left it where it was (it couldn't, or chose not to) doesn't start over and over.
284
+ this.tookTasks.add(`${session.room} ${next.id}`)
285
+ if (this.tookTasks.size > 500) this.tookTasks.delete(this.tookTasks.values().next().value)
286
+ this.enqueue({ kind: 'task', session, id: next.id, title: next.title, by: next.by || '' })
239
287
  }
240
288
 
241
289
  /** Runs one piece of work to its end (or its question). */
242
- async run (job) {
290
+ async run (job, { busyWith = '' } = {}) {
243
291
  const { session } = job
244
292
  const c = this.config()
245
293
  if (session.stopped) this.sessions.delete(session)
@@ -257,14 +305,14 @@ export class AutoWork {
257
305
  let run = job.kind === 'reply' ? job.run : null
258
306
  const resumes = p.resumes !== false && !!(run && run.chatId)
259
307
  if (!run) {
260
- const prompt = runPrompt({ person: personOf(session), job, changeFiles: c.changeFiles, prompt: c.prompt })
308
+ const prompt = runPrompt({ person: personOf(session), job, changeFiles: c.changeFiles, prompt: c.prompt, busyWith })
261
309
  run = { id: crypto.randomBytes(6).toString('hex'), via: `auto${crypto.randomBytes(6).toString('hex')}`, room: session.room, dir: session.root, tool: p.tool, kind: job.kind, work: job.id, path: job.path || '', title: job.title, by: job.by, prompt, chatId: p.newChatId(), said: [], ts: this.now() }
262
310
  }
263
311
  if (!exe) {
264
312
  this.fail(session, run, `${p.exe} isn't installed on this computer`)
265
313
  return
266
314
  }
267
- const o = { model: c.model, changeFiles: c.changeFiles, chatId: run.chatId }
315
+ const o = { model: c.model, changeFiles: c.changeFiles && !busyWith, chatId: run.chatId }
268
316
  const args = resumes
269
317
  ? p.resume({ ...o, prompt: replyPrompt({ run, reply: job.reply, resumes: true }) })
270
318
  : p.start({ ...o, prompt: job.kind === 'reply' ? replyPrompt({ run, reply: job.reply, resumes: false }) : run.prompt })
package/src/hooks.js CHANGED
@@ -39,7 +39,12 @@ export const HOOK_COMMAND = 'quilt hook' // what older versions wrote
39
39
  export const hookCommand = (dialect = '') => quiltShellCommand(dialect ? ['hook', dialect] : ['hook'])
40
40
  /** Is this hook entry one of Quilt's (any version, any tool)? */
41
41
  export const isQuiltHook = (h) => !!h && typeof h.command === 'string' && (h.command.startsWith(HOOK_COMMAND) || /(quilt\.js|\/quilt)"? hook( \w+)?$/.test(h.command))
42
- export function hookSettings (command = hookCommand()) {
42
+ /**
43
+ * `stop`: the hook as the AI finishes a turn, for an agent of its own (it is held there to answer
44
+ * what is written to it and to hand on files someone waits for). A person's AI sessions get none:
45
+ * nothing written to the person is theirs to answer, and their claims go when the session ends.
46
+ */
47
+ export function hookSettings (command = hookCommand(), { stop = true } = {}) {
43
48
  const run = { type: 'command', command, timeout: 10 }
44
49
  const edits = [...CLAUDE_EDITS, ...CURSOR_EDITS].join('|')
45
50
  return {
@@ -47,20 +52,20 @@ export function hookSettings (command = hookCommand()) {
47
52
  UserPromptSubmit: [{ hooks: [run] }],
48
53
  PreToolUse: [{ matcher: edits, hooks: [run] }],
49
54
  PostToolUse: [{ matcher: edits, hooks: [run] }],
50
- Stop: [{ hooks: [run] }],
55
+ ...(stop ? { Stop: [{ hooks: [run] }] } : {}),
51
56
  SessionEnd: [{ hooks: [run] }]
52
57
  }
53
58
  }
54
59
 
55
60
  /** Gemini CLI's hooks (its user settings.json): the same events under its names, timeouts in milliseconds. */
56
- export function geminiHookSettings (command = hookCommand('gemini')) {
61
+ export function geminiHookSettings (command = hookCommand('gemini'), { stop = true } = {}) {
57
62
  const run = { type: 'command', command, timeout: 10000 }
58
63
  return {
59
64
  SessionStart: [{ hooks: [run] }],
60
65
  BeforeAgent: [{ hooks: [run] }],
61
66
  BeforeTool: [{ matcher: GEMINI_EDITS.join('|'), hooks: [run] }],
62
67
  AfterTool: [{ matcher: GEMINI_EDITS.join('|'), hooks: [run] }],
63
- AfterAgent: [{ hooks: [run] }],
68
+ ...(stop ? { AfterAgent: [{ hooks: [run] }] } : {}),
64
69
  SessionEnd: [{ hooks: [run] }]
65
70
  }
66
71
  }
@@ -130,11 +130,13 @@ export function upsertHooks (file, ours) {
130
130
  const { json } = readJson(file)
131
131
  if (json === null) return 'skipped'
132
132
  const hooks = json.hooks && typeof json.hooks === 'object' && !Array.isArray(json.hooks) ? { ...json.hooks } : {}
133
+ // An event given no entries (`[]`) loses Quilt's: an event left with nothing goes.
133
134
  for (const [event, entries] of Object.entries(ours)) {
134
135
  const kept = (Array.isArray(hooks[event]) ? hooks[event] : [])
135
136
  .map((e) => (e && Array.isArray(e.hooks) ? { ...e, hooks: e.hooks.filter((h) => !isQuiltHook(h)) } : e))
136
137
  .filter((e) => e && (!Array.isArray(e.hooks) || e.hooks.length))
137
- hooks[event] = [...kept, ...entries]
138
+ if (kept.length || entries.length) hooks[event] = [...kept, ...entries]
139
+ else delete hooks[event]
138
140
  }
139
141
  return upsertJsonKey(file, ['hooks'], hooks)
140
142
  }
@@ -168,7 +170,7 @@ export function knownTools ({ home = os.homedir(), platform = process.platform,
168
170
  { name: 'Cursor', dir: path.join(home, '.cursor'), file: path.join(home, '.cursor', 'mcp.json'), write: json(['mcpServers', 'quilt']) },
169
171
  { name: 'Windsurf', dir: path.join(home, '.codeium', 'windsurf'), file: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), write: json(['mcpServers', 'quilt']) },
170
172
  { name: 'Codex', dir: codexHome, file: path.join(codexHome, 'config.toml'), write: upsertCodexToml },
171
- { name: 'Gemini CLI', dir: path.join(home, '.gemini'), file: path.join(home, '.gemini', 'settings.json'), write: (f, s, launch) => { const r = json(['mcpServers', 'quilt'])(f, s); return r === 'skipped' ? r : both(r, upsertHooks(f, geminiHookSettings(quiltShellCommand(['hook', 'gemini'], launch)))) } },
173
+ { name: 'Gemini CLI', dir: path.join(home, '.gemini'), file: path.join(home, '.gemini', 'settings.json'), write: (f, s, launch, { stop = false } = {}) => { const r = json(['mcpServers', 'quilt'])(f, s); return r === 'skipped' ? r : both(r, upsertHooks(f, { AfterAgent: [], ...geminiHookSettings(quiltShellCommand(['hook', 'gemini'], launch), { stop }) })) } },
172
174
  { name: 'GitHub Copilot CLI', dir: path.join(home, '.copilot'), file: path.join(home, '.copilot', 'mcp-config.json'), write: json(['mcpServers', 'quilt'], (s) => ({ type: 'local', ...s, tools: ['*'] })) },
173
175
  { name: 'Zed', dir: platform === 'win32' ? path.join(appData, 'Zed') : path.join(xdg, 'zed'), file: platform === 'win32' ? path.join(appData, 'Zed', 'settings.json') : path.join(xdg, 'zed', 'settings.json'), write: (f, s) => upsertZed(f, { source: 'custom', command: s.command, args: s.args, env: s.env || {} }) },
174
176
  { name: 'opencode', dir: path.join(xdg, 'opencode'), file: path.join(xdg, 'opencode', 'opencode.json'), write: json(['mcp', 'quilt'], (s) => ({ type: 'local', command: [s.command, ...s.args], enabled: true, ...(s.env ? { environment: s.env } : {}) })) },
@@ -186,14 +188,14 @@ export function knownTools ({ home = os.homedir(), platform = process.platform,
186
188
  * Returns [{ name, file, result }] for every tool found ('added' | 'updated' |
187
189
  * 'unchanged' | 'skipped' | 'busy' | 'failed', with `error` for failed).
188
190
  */
189
- export function registerEverywhere ({ launch = quiltLaunch(), ...where } = {}) {
191
+ export function registerEverywhere ({ launch = quiltLaunch(), stop = false, ...where } = {}) {
190
192
  const server = mcpServer(launch)
191
193
  const out = []
192
194
  for (const t of knownTools(where)) {
193
195
  if (!fs.existsSync(t.dir)) continue
194
196
  try {
195
197
  if (t.busy && t.busy(t.file)) { out.push({ name: t.name, file: t.file, result: 'busy' }); continue }
196
- out.push({ name: t.name, file: t.file, result: t.write(t.file, server, launch) })
198
+ out.push({ name: t.name, file: t.file, result: t.write(t.file, server, launch, { stop }) })
197
199
  } catch (err) {
198
200
  out.push({ name: t.name, file: t.file, result: 'failed', error: err.message })
199
201
  }
@@ -0,0 +1 @@
1
+ The rules for this folder are in `seam/AGENTS.md`: seam is source control for more than Quilt.
@@ -0,0 +1 @@
1
+ @../../seam/AGENTS.md
@@ -179,8 +179,6 @@ export async function runSeamTool (name, args, { client, found = null }) {
179
179
  }
180
180
  }
181
181
 
182
- /** Whether this process offers the source-control tools (QUILT_SOURCE, as the API's routes). */
183
- export const quiltToolsOn = (env = process.env) => /^(1|true|yes)$/i.test(env.QUILT_SOURCE || '')
184
182
 
185
183
  /**
186
184
  * Registers every tool on an MCP server. `client()` makes the quilt client for a call (it may
@@ -27,7 +27,7 @@ const connected = (room, account) => [...room.conns.keys()].some((ws) => ws.pass
27
27
 
28
28
  /**
29
29
  * Looks at `room` for each cloud AI it knows: what is new for it. Returns the cloud AIs to wake
30
- * ([{ agent, room }]); `changed` says whether meta needs saving. Pure but for the room it reads.
30
+ * ([{ agent, room, why }], why: 'task' or 'message'); `changed` says whether meta needs saving. Pure but for the room it reads.
31
31
  */
32
32
  export function cloudAisToWake (room) {
33
33
  const out = []
@@ -46,7 +46,9 @@ export function cloudAisToWake (room) {
46
46
  entry.inbox = got.state
47
47
  changed = true
48
48
  if (seeded || !got.events.length) continue
49
- if (!connected(room, entry.agent)) out.push({ agent: entry.agent.slice('agent:'.length), room: room.name })
49
+ // Why: a task for it, or a message (its person may have messages wait for its next wake).
50
+ const why = got.events.some((e) => e.kind === 'task') ? 'task' : 'message'
51
+ if (!connected(room, entry.agent)) out.push({ agent: entry.agent.slice('agent:'.length), room: room.name, why })
50
52
  }
51
53
  return { wake: out, changed }
52
54
  }
@@ -63,7 +65,7 @@ export function cloudAiOfPerson (room, ws) {
63
65
  if (!pass || pass.kind !== 'person') return null
64
66
  const entry = (room.meta.cloudAis || {})[`person:${pass.sub}`]
65
67
  if (!entry || !entry.agent || connected(room, entry.agent)) return null
66
- return { agent: entry.agent.slice('agent:'.length), room: room.name }
68
+ return { agent: entry.agent.slice('agent:'.length), room: room.name, why: 'person' }
67
69
  }
68
70
 
69
71
  /**
package/src/runner.js CHANGED
@@ -169,10 +169,11 @@ export async function runSession ({ dir, conn, name, tool, color = null, shareBy
169
169
  remember({ dir, room: conn.room, server: conn.server, name: session.name, tool, kind, workspace: savedWorkspace })
170
170
 
171
171
  // Every AI tool on this computer can reach Quilt (its MCP server, by absolute path), with nothing to set up.
172
- registerOnStart((line) => session.log(line))
172
+ // An agent of its own also gets the hook as its AI finishes a turn; a person's AI sessions don't (hooks.js hookSettings).
173
+ registerOnStart((line) => session.log(line), { stop: kind === 'agent' })
173
174
  // Claude Code also applies Quilt's rules by itself through hooks (src/hooks.js), in this person's own
174
175
  // settings file; everyone's session writes its own, so the rule holds for everyone in the room.
175
- try { if (installHooks(dir)) session.log(`🪝 added Quilt's Claude Code hooks to ${HOOKS_FILE}: files are checked before Claude edits them`) } catch {}
176
+ try { if (installHooks(dir, { stop: kind === 'agent' })) session.log(`🪝 added Quilt's Claude Code hooks to ${HOOKS_FILE}: files are checked before Claude edits them`) } catch {}
176
177
  releaseLeftoverHookClaims(session).then((n) => { if (n) session.log(`🔓 released ${n} claim(s) left by an earlier AI session`) }).catch(() => {})
177
178
 
178
179
  // Share this person's AI chat (Claude Code, Cursor) with the room.