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 +17 -0
- package/package.json +1 -1
- package/src/account.js +3 -3
- package/src/ai-vm.js +26 -2
- package/src/api/memory-store.js +2 -2
- package/src/api/routes/ai-vm.js +70 -4
- package/src/api/server.js +4 -4
- package/src/api/supabase-store.js +3 -3
- package/src/autowork.js +54 -6
- package/src/hooks.js +9 -4
- package/src/integrations.js +6 -4
- package/src/quilts/AGENTS.md +1 -0
- package/src/quilts/CLAUDE.md +1 -0
- package/src/quilts/tools.js +0 -2
- package/src/relay-ai-vm.js +5 -3
- package/src/runner.js +3 -2
- package/src/session.js +16 -4
- package/src/setup.js +9 -4
- package/src/ui/app.css +162 -31
- package/src/ui/cloud-ai.js +371 -153
- package/src/ui/home.js +29 -16
- package/src/ui/session.js +12 -1
- package/src/ui-server.js +21 -4
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
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
|
-
|
|
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
|
/**
|
package/src/api/memory-store.js
CHANGED
|
@@ -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)) },
|
package/src/api/routes/ai-vm.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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 (
|
|
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
|
-
|
|
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
|
}
|
package/src/integrations.js
CHANGED
|
@@ -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
|
package/src/quilts/tools.js
CHANGED
|
@@ -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
|
package/src/relay-ai-vm.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|