heyquilt 0.3.108 → 0.3.109

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/README.md CHANGED
@@ -378,16 +378,14 @@ the way the agent listens for it, and mentions are marked in every message.
378
378
 
379
379
  ### Your AI on its own cloud machine
380
380
 
381
- Quilt runs a person's AI only when they turn "Work on its own" on in Settings → Your AI, and
382
- then only their own AI tool with their own account: on their own computer, or on their own
383
- cloud VM they connected. **Connect Claude**, **Connect Codex** or **Connect Cursor** there
384
- starts a small machine for that person alone (never the relay) and signs the tool in with its
385
- own login: the app shows the link and code, and Quilt never asks for, keeps or passes on the
386
- tool's token, or pays for its use. From then on it is one of the person's AI sessions,
387
- "Daniel · cloud" under "Daniel's AI": it joins the sessions they join and, while none of
388
- their AI sessions on a computer is live, takes tasks for their AI and answers what is written
389
- to it, with its own model, instructions and daily cap. It sleeps when idle and wakes when it
390
- is needed. Disconnect deletes the machine and its disk.
381
+ Quilt runs a person's AI only on their own cloud machine they connected, with their own AI
382
+ tool and their own account. **Connect Claude**, **Connect Codex** or **Connect Cursor** in
383
+ Settings → Your AI starts a small machine for that person alone (never the relay) and signs the
384
+ tool in with its own login: the app shows the link and code, and Quilt never asks for, keeps or
385
+ passes on the tool's token, or pays for its use. From then on it is "Daniel's AI", the only
386
+ member by that name: it joins the sessions they join, takes tasks for their AI and answers what
387
+ is written to it, with its own model, instructions and daily cap. It sleeps when idle and wakes
388
+ when it is needed. Disconnect deletes the machine and its disk.
391
389
 
392
390
  ### Webhooks: an agent sets up its own push
393
391
 
package/RELEASES.md CHANGED
@@ -11,6 +11,17 @@ 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.109 — 2026-10-20
15
+
16
+ "Your AI" is your cloud AI, and only that.
17
+
18
+ - **Each AI session is shown as itself.** The member list, chat, file locks and the Loom show each of your AI sessions by its own name ("Daniel · file-queue"), instead of folding them all into "Daniel's AI".
19
+ - **"Daniel's AI" is your cloud AI.** Connected, your cloud AI is named "Daniel's AI": what anyone writes or hands to your AI goes to it, whether or not you have AI sessions open. Without one, nobody is "Daniel's AI".
20
+ - **Nothing is pushed to the AI sessions on your computer.** Messages, mentions and tasks for your AI no longer land in whichever session you used last, and your computer never starts a run on its own: work for your AI waits on the board until you ask your AI for it. The "When work comes to your AI" settings are gone with it.
21
+ - **Your cloud AI talks caveman.** It answers in short, plain sentences (the caveman skill, for Claude Code, Codex and Cursor), so its notes cost fewer tokens. Code, commands, paths and commit messages stay exact.
22
+ - **Workspaces are off for now.** Home lists your sessions again, and workspaces, workspace invites, the Agents page and your AI's workspace library tools (and `quilt workspace`) are gone from the app. Nothing in them is deleted; they come back if they turn out to be missed.
23
+ - **The git panel shows only your work not committed.** It used to count every file anyone in the session had changed (14, say), with nothing about whose they were. Now it lists just yours and your AIs', in two lines: boxed (only in your folder so far) and shared (everyone has them); click either to see its files. The count in the top bar is yours too, and Commit and push commits only your files, leaving everyone else's for them.
24
+
14
25
  ## 0.3.108 — 2026-10-19
15
26
 
16
27
  - **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.
package/bin/quilt.js CHANGED
@@ -558,6 +558,7 @@ async function say () {
558
558
  async function workspaceCmd () {
559
559
  const { WORKSPACE_USAGE, runWorkspaceCommand } = await import('../src/workspace-cli.js')
560
560
  const [sub, ...rest] = argv
561
+ if (!(await import('../src/app-workspaces.js')).appWorkspaces()) fail('Workspaces are turned off in this version of Quilt.')
561
562
  if (!sub || sub === 'help' || sub === '--help' || sub === '-h') return console.log(WORKSPACE_USAGE)
562
563
  const { localWorkspaceAccess } = await import('../src/mcp.js')
563
564
  const { findDaemon } = await import('../src/control.js')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyquilt",
3
- "version": "0.3.108",
3
+ "version": "0.3.109",
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/ai-vm.js CHANGED
@@ -74,6 +74,53 @@ export async function warmTool (exe, stop = () => false) {
74
74
  return warmFolder(root, BIG_FILE, stop)
75
75
  }
76
76
 
77
+ // What the image installed into a home folder of its own for every machine (vm/Dockerfile: caveman
78
+ // for Claude Code, Codex and Cursor); each machine's home (its volume) takes it in at start.
79
+ export const IMAGE_HOME = '/opt/cavehome'
80
+
81
+ /** Two settings files as one: `over`'s keys added to `base`, objects merged, lists joined, `base` kept where both say. */
82
+ function mergeJson (base, over) {
83
+ if (Array.isArray(base) && Array.isArray(over)) return [...base, ...over.filter((x) => !base.some((y) => JSON.stringify(y) === JSON.stringify(x)))]
84
+ if (!base || !over || typeof base !== 'object' || typeof over !== 'object' || Array.isArray(base) || Array.isArray(over)) return base
85
+ const out = { ...base }
86
+ for (const [k, v] of Object.entries(over)) out[k] = k in base ? mergeJson(base[k], v) : v
87
+ return out
88
+ }
89
+
90
+ /**
91
+ * Takes in what the image installed for every machine (`from`) into this machine's home (`to`):
92
+ * files it lacks are copied, JSON settings it has are merged (its own kept where both say), and
93
+ * nothing of its own is replaced. Returns how many files changed.
94
+ */
95
+ export function seedHome (from, to) {
96
+ let n = 0
97
+ const walk = (rel) => {
98
+ let entries = []
99
+ try { entries = fs.readdirSync(path.join(from, rel), { withFileTypes: true }) } catch { return }
100
+ for (const e of entries) {
101
+ const r = path.join(rel, e.name)
102
+ const src = path.join(from, r)
103
+ const dst = path.join(to, r)
104
+ if (e.isDirectory()) { walk(r); continue }
105
+ if (!e.isFile()) continue
106
+ try {
107
+ if (!fs.existsSync(dst)) {
108
+ fs.mkdirSync(path.dirname(dst), { recursive: true })
109
+ fs.copyFileSync(src, dst)
110
+ fs.chmodSync(dst, fs.statSync(src).mode & 0o777)
111
+ n++
112
+ } else if (e.name.endsWith('.json')) {
113
+ const have = JSON.parse(fs.readFileSync(dst, 'utf8'))
114
+ const merged = mergeJson(have, JSON.parse(fs.readFileSync(src, 'utf8')))
115
+ if (JSON.stringify(merged) !== JSON.stringify(have)) { fs.writeFileSync(dst, JSON.stringify(merged, null, 2) + '\n'); n++ }
116
+ }
117
+ } catch {}
118
+ }
119
+ }
120
+ walk('')
121
+ return n
122
+ }
123
+
77
124
  /** Reads every file of `min` bytes or more under `root` into memory, until `stop()`; returns how many bytes. */
78
125
  export async function warmFolder (root, min = 0, stop = () => false) {
79
126
  let bytes = 0
@@ -114,10 +161,11 @@ export class AiVm {
114
161
  * reaches outside is injectable for tests: `fetch`, `login` (spawnLogin), `status` (runQuiet),
115
162
  * `join` (runSession), `agentJoin`/`agentAccess` (agent-join.js), `autowork`, `which`.
116
163
  */
117
- constructor ({ api, joinLink = '', spareKey = '', home = process.env.HOME, fetch: fetchImpl = globalThis.fetch, login = spawnLogin, status = runQuiet, warm = warmTool, join = null, agent = null, autowork = null, which = null, now = Date.now, log = console.log, idleMs = IDLE_MS, reportMs = REPORT_MS, port = 8080, waitParam = '1', claimEveryMs = CLAIM_EVERY_MS, codeMs = CODE_MS }) {
164
+ constructor ({ api, joinLink = '', spareKey = '', imageHome = null, home = process.env.HOME, fetch: fetchImpl = globalThis.fetch, login = spawnLogin, status = runQuiet, warm = warmTool, join = null, agent = null, autowork = null, which = null, now = Date.now, log = console.log, idleMs = IDLE_MS, reportMs = REPORT_MS, port = 8080, waitParam = '1', claimEveryMs = CLAIM_EVERY_MS, codeMs = CODE_MS }) {
118
165
  this.api = String(api || '').replace(/\/+$/, '')
119
166
  this.joinLink = joinLink
120
167
  this.spareKey = spareKey
168
+ this.imageHome = imageHome
121
169
  this.warm = warm
122
170
  this.claimEveryMs = claimEveryMs
123
171
  this.codeMs = codeMs
@@ -235,6 +283,8 @@ export class AiVm {
235
283
 
236
284
  async start () {
237
285
  fs.mkdirSync(this.dir, { recursive: true })
286
+ // What the image installed for every machine (caveman for each AI tool) comes into this home.
287
+ if (this.imageHome) { const n = seedHome(this.imageHome, this.home); if (n) this.log(`took in ${n} file(s) of the image's tool settings`) }
238
288
  if (this.spareKey && !(await this.hasAgent())) await this.spare()
239
289
  if (this.stopped) return
240
290
  await this.ensureAgent()
@@ -512,7 +562,7 @@ export function onPath (names, { env = process.env } = {}) {
512
562
  /** `quilt ai-vm`, from the machine's environment. */
513
563
  export async function runAiVm (env = process.env) {
514
564
  if (!env.QUILT_API) throw new Error('QUILT_API is not set: this command runs on a cloud AI machine Quilt started.')
515
- const vm = new AiVm({ api: env.QUILT_API, joinLink: env.QUILT_AI_VM_JOIN || '', spareKey: env.QUILT_AI_VM_SPARE || '', home: env.HOME, which: (names) => onPath(names), port: Number(env.PORT || 8080) })
565
+ const vm = new AiVm({ api: env.QUILT_API, joinLink: env.QUILT_AI_VM_JOIN || '', spareKey: env.QUILT_AI_VM_SPARE || '', imageHome: fs.existsSync(IMAGE_HOME) ? IMAGE_HOME : null, home: env.HOME, which: (names) => onPath(names), port: Number(env.PORT || 8080) })
516
566
  const shutdown = async () => { await vm.stop(); process.exit(0) }
517
567
  process.on('SIGINT', shutdown)
518
568
  process.on('SIGTERM', shutdown)
package/src/api/server.js CHANGED
@@ -41,8 +41,7 @@ import { roomAccessFrom } from './access.js'
41
41
  import { joiningRoom, sessionArg } from '../mcp-room.js'
42
42
  import { issueRoutes } from './routes/issues.js'
43
43
  import { aiVmRoutes, cloudAiOf, WAIT_MS as AI_VM_WAIT_MS } from './routes/ai-vm.js'
44
- import { personaName } from '../persona.js'
45
- import { CLOUD_LABEL } from '../cloud-ai.js'
44
+ import { aiName } from '../persona.js'
46
45
  import { routeName, cleanEvent } from './issues.js'
47
46
  import { secretRoutes } from './routes/secrets.js'
48
47
 
@@ -146,8 +145,8 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
146
145
  }
147
146
 
148
147
  /**
149
- * Who a pass for an agent is for. A person's cloud AI (routes/ai-vm.js) is one of its person's
150
- * AI sessions: named like one ("Dan · cloud"), with its person's account (`of`) and name
148
+ * Who a pass for an agent is for. A person's cloud AI (routes/ai-vm.js) is that
149
+ * person's AI: named "<person>'s AI" ("Dan's AI"), with its person's account (`of`) and name
151
150
  * (`ofName`), which the relay lets in where its person stands.
152
151
  */
153
152
  async function agentHolder (agent) {
@@ -156,7 +155,7 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
156
155
  if (!vm) return holder
157
156
  const p = await store.profile(vm.userId)
158
157
  const person = String((p && p.name) || 'Quilt user').slice(0, 64)
159
- return { ...holder, name: personaName(person, CLOUD_LABEL).slice(0, 64), of: `person:${vm.userId}`, ofName: person }
158
+ return { ...holder, name: aiName(person.slice(0, 58)), of: `person:${vm.userId}`, ofName: person }
160
159
  }
161
160
 
162
161
  // Resume signatures seen in the last window, so a copied one can't be used again: here, and in
@@ -0,0 +1,4 @@
1
+ // Workspaces are off on this computer until after the MVP (the cofounders' call, 2026-10-19): the
2
+ // app, its agents' workspace tools and `quilt workspace` act as if the accounts API had them off,
3
+ // whatever the API says. QUILT_APP_WORKSPACES=1 brings them back (tests, and when they return).
4
+ export const appWorkspaces = (env = process.env) => /^(1|true)$/i.test(env.QUILT_APP_WORKSPACES || '')
package/src/autowork.js CHANGED
@@ -62,8 +62,12 @@ export function tokensOn (entries, ts) {
62
62
  return Object.values(d).reduce((n, t) => n + t.input + t.output, 0)
63
63
  }
64
64
 
65
- /** What a run is asked to do: the work, and how to talk while nobody watches. */
66
- export function runPrompt ({ person, job, changeFiles, prompt = '' }) {
65
+ /**
66
+ * What a run is asked to do: the work, and how to talk while nobody watches. `voice`: the
67
+ * machine's caveman mode (CAVEMAN_DEFAULT_MODE on a cloud AI machine, vm/Dockerfile), said
68
+ * here too so every tool talks that way, not only those whose own hooks turn it on.
69
+ */
70
+ export function runPrompt ({ person, job, changeFiles, prompt = '', voice = process.env.CAVEMAN_DEFAULT_MODE || '' }) {
67
71
  const lines = [
68
72
  `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.`,
69
73
  ''
@@ -76,6 +80,7 @@ export function runPrompt ({ person, job, changeFiles, prompt = '' }) {
76
80
  `Answer ${job.by || 'them'} with quilt_message. If they ask for work, do it as ${person}'s AI would, and say in chat what you did.`)
77
81
  }
78
82
  if (prompt) lines.push('', `${person}'s own instructions for you:`, prompt)
83
+ 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.`)
79
84
  lines.push('', 'Before your first change, read the project\'s rules with quilt_project_rules.')
80
85
  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.`)
81
86
  lines.push(
@@ -291,7 +296,7 @@ export class AutoWork {
291
296
  fail (session, run, why) {
292
297
  const where = session.aiOf ? 'your cloud AI (Settings → Your AI)' : 'this computer'
293
298
  const text = SIGNED_OUT.test(String(why)) ? `${run.tool || 'your AI tool'} isn't signed in on ${where}` : String(why).replace(/[.\s]+$/, '').slice(0, 300)
294
- this.notice(session, `Couldn't run your AI for ${run.title || 'that work'}: ${text}. It's still waiting for your AI in your chat.`, run)
299
+ this.notice(session, `Couldn't run your AI for ${run.title || 'that work'}: ${text}. It's still waiting for your AI.`, run)
295
300
  session.dropAutoPersona(run.via)
296
301
  }
297
302
 
package/src/cloud-ai.js CHANGED
@@ -1,13 +1,10 @@
1
- // A person's AI on its own cloud machine (docs/superpowers/specs/2026-10-17-ai-vm.md) is one of
2
- // that person's AI sessions: its presence says whose (`aiOf`), and it answers for
3
- // "<person>'s AI" only while none of the person's AI sessions on a computer is live. Pure, so
4
- // the relay (which wakes a suspended one) and every app (which shows it and reads its inbox)
5
- // decide the same way.
1
+ // A person's AI on its own cloud machine (docs/superpowers/specs/2026-10-17-ai-vm.md): its
2
+ // presence says whose (`aiOf`), and it is the only "<person>'s AI": that is its name, and what
3
+ // is written to it or handed to the person's AI is its, whether or not they have AI sessions
4
+ // open on a computer (those answer only to their own names). Pure, so the relay (which wakes a
5
+ // suspended one) and every app (which shows it and reads its inbox) decide the same way.
6
6
  import { aiName } from './ui/chat.js'
7
7
 
8
- /** The label a cloud AI's name carries: "Daniel · cloud". */
9
- export const CLOUD_LABEL = 'cloud'
10
-
11
8
  /**
12
9
  * Whether one of `person`'s AI sessions on a computer is live: a presence of theirs (not a
13
10
  * cloud AI's) publishing an AI session that isn't a "work on its own" run.
@@ -31,17 +28,17 @@ export function localAiNames (states, person) {
31
28
  }
32
29
 
33
30
  /**
34
- * How a cloud AI named `name`, of `person`, reads the inbox (inbox.js): its own name always;
35
- * "<person>'s AI" and tasks for it only while no AI session of theirs on a computer is live.
31
+ * How a cloud AI named `name`, of `person`, reads the inbox (inbox.js): its own name and
32
+ * "<person>'s AI" (the same, once it has rejoined under it), and tasks for the person's AI.
33
+ * Its person's AI sessions on computers don't wake it: they work for the same person.
36
34
  */
37
35
  export function cloudReader ({ name, person, states = [], aliases = [] }) {
38
- const local = localAiLive(states, person)
39
36
  return {
40
37
  name,
41
- aliases: local ? [...aliases] : [...aliases, aiName(person)],
38
+ aliases: [...new Set([...aliases, aiName(person)])].filter((n) => n !== name),
42
39
  agent: true,
43
40
  asAi: false,
44
- ...(local ? {} : { of: person }),
41
+ of: person,
45
42
  own: localAiNames(states, person)
46
43
  }
47
44
  }
package/src/hooks.js CHANGED
@@ -157,8 +157,8 @@ async function sessionStart (api, state, say, name) {
157
157
  // Only what arrives from now on is for this Claude; what came before is for quilt_inbox.
158
158
  const { seq } = await api('POST', '/inbox', { after: 0 }).catch(() => ({ seq: 0 }))
159
159
  state.update((s) => { s.after = seq || 0 })
160
- // A person's AI sessions are one name to people: "Daniel's AI (3 sessions)".
161
- const who = st.peers.length ? foldPersonas(st.peers).map((p) => p.sessions ? `${p.name} (${p.sessions.length === 1 ? 'one AI session' : `${p.sessions.length} AI sessions`}${p.mine ? ', you among them' : ''})` : p.name + (p.kind === 'agent' ? ' (AI agent)' : '')).join(', ') : 'nobody else yet'
160
+ // Each AI session is a member by its own name.
161
+ const who = st.peers.length ? foldPersonas(st.peers).map((p) => p.name + (p.aiOf ? ` (${p.mine ? 'one of your person\'s AI sessions' : `an AI session of ${p.aiOf}`})` : p.kind === 'agent' ? ' (AI agent)' : '')).join(', ') : 'nobody else yet'
162
162
  const parts = [
163
163
  `This folder is in a live Quilt session (room ${st.room}) with ${who}. Files can change underneath you at any time; re-read a file right before editing it.`,
164
164
  'Quilt claims each file for you the moment you edit it, and releases those claims when you finish. ' +
@@ -167,7 +167,7 @@ async function sessionStart (api, state, say, name) {
167
167
  'When someone asks for a file you hold, finish your change, then hand it off with quilt_handoff and your context; you cannot finish before you do.',
168
168
  'Messages from collaborators, mentions of you and tasks handed to you are shown to you as you work; answer with quilt_message and take a task with quilt_move_task.',
169
169
  'You are a member of your own in the session, apart from your person and their other AI sessions, named after your work (your git branch, or the first thing you say you are doing); rename yourself with quilt_name_session. ' +
170
- 'People see all of a person\'s AI sessions as one, "<person>\'s AI": write to another person\'s AI by that name, and what is written to your person\'s AI reaches whichever of their sessions was active last (with none of them live, their cloud AI, if they connected one).'
170
+ 'People see each AI session by its own name. "<person>\'s AI" ("Daniel\'s AI") is only that person\'s cloud AI, when they connected one (Quilt runs their own AI tool for them on their own cloud machine): write to it by that name. Nothing written or handed to "<person>\'s AI" comes to you; when your person asks you to work on tasks, take them from the board (quilt_tasks).'
171
171
  ]
172
172
  const duties = await api('GET', '/duties').catch(() => ({}))
173
173
  if (duties.pickup && duties.pickup !== 'off') parts.push(`This person lets you pick up work from the task board by yourself (${duties.pickup === 'any' ? 'tasks assigned to you, then unassigned ones' : 'tasks assigned to you'}): when you finish, you are handed the next one.`)
package/src/inbox.js CHANGED
@@ -101,7 +101,8 @@ export function scanInbox ({ messages = [], tasks = [], reader, now = Date.now()
101
101
  else if (follows) events.push({ id: m.id, kind: 'mention', by: m.by, text, ts: m.ts, follows })
102
102
  }
103
103
  const mine = []
104
- for (const t of tasks) {
104
+ // `tasks: false`: a person's own inbox on a computer takes no tasks (their AI asks the board).
105
+ for (const t of reader && reader.tasks === false ? [] : tasks) {
105
106
  // An AI session's tasks: given to it by name (as a member), or to its person's AI.
106
107
  const its = assignedToReader(t, reader) || names.slice(1).some((n) => assignedToReader(t, { ...reader, name: n })) ||
107
108
  (reader && reader.of && (t.assignee === me || assignedToReader(t, { name: reader.of, asAi: true })))
package/src/mcp.js CHANGED
@@ -40,6 +40,7 @@ import { getSettings } from './settings.js'
40
40
  import { mergeAction } from './merges.js'
41
41
  import { CLASH_DESCRIPTION, clashSchema } from './relay-clash.js'
42
42
  import { describeSecretNames, SECRET_NAMES_DESCRIPTION, RUN_GUIDE } from './secret-names.js'
43
+ import { appWorkspaces } from './app-workspaces.js'
43
44
 
44
45
  // The board's columns, from tasks.js, so this and the hosted MCP (relay-mcp.js) never drift apart.
45
46
  const COLUMN_IDS_LIST = COLUMNS.map((c) => c.id)
@@ -79,7 +80,7 @@ export const MCP_INSTRUCTIONS =
79
80
  'and move a task with quilt_move_task when you start or finish it; quilt_task reads one in full with its comments, and quilt_comment_task leaves a note or handoff on it. ' +
80
81
  'quilt_history tells you who changed which file, when, with the diff: read it for the files you are about to touch. ' +
81
82
  'If git refuses to pull because untracked files would be overwritten, those files came from the session: quilt_status lists them under Pulling (and whether they match); make way and pull with rm <files> && git pull --autostash, and Quilt keeps them for everyone. ' +
82
- 'If the person you work for lets their AI pick up work by itself (their Quilt settings: on their own computer, or on their own cloud VM they connected), finishing (quilt_set_work done, a task to QA or Done) hands you your next task from the board: start it. ' +
83
+ 'If the person you work for lets their AI pick up work by itself (their cloud AI, on the cloud machine they connected), finishing (quilt_set_work done, a task to QA or Done) hands you your next task from the board: start it. ' +
83
84
  'Before you change files, call quilt_before_edit with their paths: it tells you whether each is yours to edit (claiming free ones for you, so partners are refused instead of overwriting you), ' +
84
85
  'and shows what people said about those files in chat, so you know what was asked or planned before you change them. ' +
85
86
  'Do not edit a file it refuses: ask for it in its file queue with quilt_request_file (a title like "Working on <what> for <task>" and up to 300 characters on your plan) and carry on with other work; you are woken when it is handed to you, with the holder\'s context. ' +
@@ -104,7 +105,7 @@ export const MCP_INSTRUCTIONS =
104
105
  'When you are about to answer someone in chat, quilt_typing shows them you are typing; sending clears it. ' +
105
106
  'Chat: ' + CHAT_RULES.replace(/^Send a chat message\. /, '') + ' A message that needs nothing back is settled with quilt_inbox (no_reply: [its id]), not answered. ' +
106
107
  'To be woken instead of polling, subscribe to the quilt://inbox resource (you are told when something new arrives), or quilt_webhook_subscribe POSTs each one to a URL of yours as it happens. ' +
107
- 'You are a member of your own in the session, apart from your person and their other AI sessions, named "<their first name> · <label>" after your work (your git branch, or what you first say you are doing); rename yourself with quilt_name_session. Your messages, inbox, claims and duties are your own. People see all of a person\'s AI sessions as one, "<person>\'s AI" ("Daniel\'s AI"): write to another person\'s AI by that name, and what is written to your person\'s AI reaches whichever of their sessions was active last (with none of them live, their cloud AI, if they connected one: Quilt runs their own AI tool for them on their own cloud VM). ' +
108
+ 'You are a member of your own in the session, apart from your person and their other AI sessions, named "<their first name> · <label>" after your work (your git branch, or what you first say you are doing); rename yourself with quilt_name_session. Your messages, inbox, claims and duties are your own. People see each AI session by its own name. "<person>\'s AI" ("Daniel\'s AI") is only that person\'s cloud AI, when they connected one (Quilt runs their own AI tool for them on their own cloud machine): write to it by that name. Nothing written or handed to "<person>\'s AI" comes to you; when your person asks you to work on tasks, take them from the board (quilt_tasks). ' +
108
109
  'Share what you are doing with quilt_share: when you start on a request (request and your plan) and when you finish (what you did and the files you changed). Partners see it in their feed, it puts your work on the task board, and it tells the host not to commit under you. ' +
109
110
  'When Claude Code is started with the quilt channel, they arrive on their own as <channel source="quilt"> events while you work: treat each like a request from that person, answer with quilt_message, and take a task with quilt_move_task. ' +
110
111
  RUN_GUIDE + ' ' +
@@ -237,7 +238,7 @@ export async function runMcp () {
237
238
  inputSchema: {
238
239
  title: z.string().describe('What needs doing, in a few words'),
239
240
  assignee: z.string().optional().describe('Who should do it: a person\'s name, or "me". Omit to leave it unassigned.'),
240
- to_ai: z.boolean().optional().describe('Assign it to that person\'s AI instead of the person. You are this person\'s AI unless you joined as your own agent: set assignee to "me" and to_ai to true to take the task yourself.'),
241
+ to_ai: z.boolean().optional().describe('Assign it to that person\'s AI (their cloud AI, when they connected one) instead of the person. To take a task yourself, set assignee to your own name.'),
241
242
  files: z.array(z.string()).max(20).optional().describe('Project files this task is about, relative paths such as src/app.js')
242
243
  }
243
244
  }, ({ title, assignee, to_ai, files }) => withDaemon(async (d) => {
@@ -281,7 +282,7 @@ export async function runMcp () {
281
282
  }, { gate: gates(gateFor('quilt_move_task'), column === 'done' && queueGate('move the task again')) }))
282
283
 
283
284
  server.registerTool('quilt_assign_task', {
284
- description: 'Assign a shared task to a person or to their AI, and optionally set the files it is about. assignee "" clears it. Set assignee to "me" and to_ai to true to take it yourself when you are that person\'s AI.',
285
+ description: 'Assign a shared task to a person, an AI session (by its own name) or a person\'s AI (their cloud AI), and optionally set the files it is about. assignee "" clears it. To take it yourself, set assignee to your own name.',
285
286
  inputSchema: {
286
287
  id: z.string().describe('Task id from quilt_tasks'),
287
288
  assignee: z.string().describe('A person\'s name, "me", or "" to unassign'),
@@ -1201,12 +1202,13 @@ export async function workspacesOn (api, fetchImpl = globalThis.fetch) {
1201
1202
  }
1202
1203
 
1203
1204
  /**
1204
- * Adds the workspace library tools when there is someone to act as (workspaceActor: this
1205
- * folder's agent, or the person signed in on this computer) and their API says workspaces are
1206
- * on. Anything else (nobody, the flag off, the API slow, unreachable or odd) adds nothing and
1205
+ * Adds the workspace library tools when workspaces are on on this computer (app-workspaces.js),
1206
+ * there is someone to act as (workspaceActor: this folder's agent, or the person signed in on
1207
+ * this computer) and their API says workspaces are on. Anything else (nobody, the flag off, the API slow, unreachable or odd) adds nothing and
1207
1208
  * never throws. Returns whether the tools were added.
1208
1209
  */
1209
1210
  export async function addWorkspaceTools (server, { fetch: fetchImpl = globalThis.fetch, projectDirs = () => [process.cwd()] } = {}) {
1211
+ if (!appWorkspaces()) return false // off on this computer (app-workspaces.js)
1210
1212
  const access = localWorkspaceAccess({ fetch: fetchImpl, projectDirs })
1211
1213
  let api
1212
1214
  try { api = access.api() } catch { return false }
package/src/persona.js CHANGED
@@ -97,5 +97,5 @@ export function labelFromFile (rel) {
97
97
  return cleanLabel(base.replace(/\.[A-Za-z0-9]+$/, ''))
98
98
  }
99
99
 
100
- // "Daniel's AI": all of a person's AI sessions as one name (pure, shared with the app).
100
+ // "Daniel's AI": a person's cloud AI's name, and only that (pure, shared with the app).
101
101
  export { AI_SUFFIX, aiName, foldPersonas } from './ui/chat.js'
package/src/relay-mcp.js CHANGED
@@ -34,7 +34,6 @@ import { UpdateCheck } from './update-check.js'
34
34
  import { TASK_WORKFLOW, pickupBrief, doneRefusal, verifiedEnough, verifiedLine, qaRefusal, qaNotesEnough, qaNotesLine, pickChecklist, MAX_VERIFIED } from './agent-task-workflow.js'
35
35
  import { HistoryLog, queryHistory, parseSince, formatHistory, currentTask, lineDiff } from './history.js'
36
36
  import { changeRefusal, TALK_REFUSED } from './session-access.js'
37
- import { aiName } from './persona.js'
38
37
  import { chatAbout, renderChatAbout, waitingOn, needsNoReply, renderUnanswered, heldRefusal, queuedFor, renderQueueNotice, renderQueued, answered, unaddressed, CHAT_RULES, RE_HELP, replyFor, renderReplied } from './duties.js'
39
38
  import { describeSubscription, WEBHOOK_EVENTS } from './webhooks.js'
40
39
  import { registerWorkspaceTools, bytesFetcher, WORKSPACE_GUIDE_HOSTED } from './workspace-tools.js'
@@ -268,7 +267,6 @@ function sessionTools (server, ctx) {
268
267
  // Everyone this agent could address: who is here and who has been in the chat.
269
268
  const memberNames = (room, doc) => {
270
269
  const names = new Set(peers(room).map((p) => p.name))
271
- for (const p of peers(room)) if (p.persona && p.of) names.add(aiName(p.of)) // "Daniel's AI"
272
270
  for (const m of seen(doc)) { if (m.by) names.add(m.by); if (m.to) names.add(m.to) }
273
271
  names.delete(me)
274
272
  return [...names].filter((n) => typeof n === 'string' && n)
package/src/session.js CHANGED
@@ -5337,9 +5337,9 @@ export class Session extends EventEmitter {
5337
5337
  const r = cloudReader({ name: this.name, person: this.aiOf, states: this.conn && this.conn.awareness ? [...this.conn.awareness.getStates().values()] : [] })
5338
5338
  return { ...r, own: [...r.own, ...own] }
5339
5339
  }
5340
- // With no AI session of ours live, what is written to "<me>'s AI" waits here for the next one.
5341
- const aliases = this.kind !== 'agent' && !this.leadPersona() ? [aiName(this.name)] : []
5342
- return { name: this.name, aliases, asAi: this.kind !== 'agent', agent: this.kind === 'agent', own }
5340
+ // Nothing written to "<me>'s AI" or handed to my AI comes here: that is the cloud AI's, and
5341
+ // AI sessions on this computer take work when their person tells them to (from the board).
5342
+ return { name: this.name, aliases: [], asAi: false, agent: this.kind === 'agent', own, ...(this.kind !== 'agent' ? { tasks: false } : {}) }
5343
5343
  }
5344
5344
 
5345
5345
  /**
@@ -5374,8 +5374,8 @@ export class Session extends EventEmitter {
5374
5374
  /** The names `via` (an AI session of ours, or this member when null) is known by in chat. */
5375
5375
  readerNames (via = null) {
5376
5376
  const p = this.persona(via)
5377
- if (p) return [p.name, ...p.aliases, aiName(this.name)]
5378
- return this.kind === 'agent' ? [this.name] : [this.name, aiName(this.name)]
5377
+ if (p) return [p.name, ...p.aliases]
5378
+ return [this.aiOf ? aiName(this.aiOf) : null, this.name].filter((n, i, a) => n && a.indexOf(n) === i)
5379
5379
  }
5380
5380
 
5381
5381
  /** Every message this member may read, the kept ones (chat-archive.js) before the room's, oldest first. */
@@ -5414,7 +5414,7 @@ export class Session extends EventEmitter {
5414
5414
  const r = (p ? p.inbox : this.inboxTracker).since(after)
5415
5415
  if (all) return { ...r, events: this.withContext(r.events, via), me: this.readerNames(via) }
5416
5416
  const msgs = this.chat.toArray().filter((m) => this.canSee(m))
5417
- const me = p ? [p.name, ...p.aliases, aiName(this.name)] : [this.name, aiName(this.name)]
5417
+ const me = this.readerNames(via)
5418
5418
  // A commit request (e.commit: its id) waits until the request is done; a note that a commit was made is shown once.
5419
5419
  const open = (e) => typeof e.commit === 'string' ? this.commitRequests.get(e.commit)?.state === 'open' : (e.kind !== 'dm' && e.kind !== 'mention') || e.queue || e.commit || e.nudge ||
5420
5420
  (!this.settledIds.has(e.id) && !answered(msgs, me, e.by, e.ts))
@@ -5435,7 +5435,6 @@ export class Session extends EventEmitter {
5435
5435
  memberNames (me = this.name) {
5436
5436
  const names = new Set(this.peerNames())
5437
5437
  if (me !== this.name) names.add(this.name) // an AI session may write to its own person
5438
- for (const p of this.status().peers) if (p.persona && p.of && !p.mine) names.add(aiName(p.of)) // "Brandon's AI"
5439
5438
  for (const m of this.chat.toArray()) if (m && this.canSee(m)) { if (m.by) names.add(m.by); if (m.to) names.add(m.to) }
5440
5439
  names.delete(me)
5441
5440
  return [...names].filter((n) => typeof n === 'string' && n)
@@ -5608,13 +5607,21 @@ export class Session extends EventEmitter {
5608
5607
 
5609
5608
  /**
5610
5609
  * Commits everything the session changed since its last commit, from this folder, with `message`,
5611
- * and pushes (the GitHub chip's Commit and push): the files here that differ from HEAD and sync.
5612
- * It goes on record as a commit request made and done here, so nobody else is asked.
5610
+ * and pushes: the files here that differ from HEAD and sync. `mine` (the git panel's Commit and
5611
+ * push): only this person's own work (myUncommitted), boxed or not. It goes on record as a commit
5612
+ * request made and done here, so nobody else is asked.
5613
5613
  */
5614
- async commitSessionWork (message) {
5614
+ async commitSessionWork (message, { mine = false } = {}) {
5615
5615
  if (!this.canCommit()) throw new Error('this folder has no git (or no branch checked out), so it can’t commit: commit with git from a folder on this branch')
5616
- const dirty = await uncommitted(this.root)
5617
- const files = dirty ? [...dirty.keys()].filter((rel) => this.syncable(rel)).slice(0, MAX_REQUEST_FILES) : []
5616
+ let files
5617
+ if (mine) {
5618
+ const own = await this.myUncommitted()
5619
+ files = own ? own.files.map((f) => f.path).filter((rel) => this.syncable(rel)).slice(0, MAX_REQUEST_FILES) : []
5620
+ if (!files.length) throw new Error('nothing of yours to commit: your work here is all in git')
5621
+ } else {
5622
+ const dirty = await uncommitted(this.root)
5623
+ files = dirty ? [...dirty.keys()].filter((rel) => this.syncable(rel)).slice(0, MAX_REQUEST_FILES) : []
5624
+ }
5618
5625
  if (!files.length) throw new Error('nothing to commit: this folder matches its last commit')
5619
5626
  const r = this.requestCommit(message, { files, by: this.name, quiet: true })
5620
5627
  return this.commitRequest(r.id)
@@ -5721,6 +5728,30 @@ export class Session extends EventEmitter {
5721
5728
  return { branch: this.git.branch, groups: uncommittedByPerson(editors), requests }
5722
5729
  }
5723
5730
 
5731
+ /** Whether a name the history gives is this person's: theirs, one of their AI sessions', or their cloud AI's. */
5732
+ ownName (name) {
5733
+ if (this.isMine(name)) return true
5734
+ if (this.aiOf || !this.conn || !this.conn.awareness) return false
5735
+ for (const [id, st] of this.conn.awareness.getStates()) if (id !== this.doc.clientID && isCloudAi(st) && st.aiOf === this.name && st.name === name) return true
5736
+ return false
5737
+ }
5738
+
5739
+ /**
5740
+ * This person's own work not committed in this folder (theirs and their AIs'; nobody else's), for
5741
+ * the app's git panel: { branch, files: [{ path, boxed }] }, `boxed` when it is in their box (in
5742
+ * this folder only so far), else everyone has it. Null without git.
5743
+ */
5744
+ async myUncommitted () {
5745
+ const work = await this.uncommittedWork()
5746
+ if (!work) return null
5747
+ const dirty = new Set(work.groups.flatMap((g) => g.files))
5748
+ const paths = new Set(work.groups.filter((g) => g.by && this.ownName(g.by)).flatMap((g) => g.files))
5749
+ const boxed = new Set()
5750
+ for (const c of this.claims.values()) if (c && c.box && this.ownClaim(c) && dirty.has(c.pattern)) boxed.add(c.pattern)
5751
+ for (const rel of boxed) paths.add(rel)
5752
+ return { branch: work.branch, files: [...paths].sort().map((p) => ({ path: p, boxed: boxed.has(p) })) }
5753
+ }
5754
+
5724
5755
  /** Looks at the open requests again soon (one came in, or one was committed). */
5725
5756
  reviewCommitsSoon (ms = 5000) {
5726
5757
  if (this.stopped || !this.canCommit()) return
@@ -5959,10 +5990,13 @@ export class Session extends EventEmitter {
5959
5990
  return { changed: plan.changes.map((c) => c.path), manual: plan.manual }
5960
5991
  }
5961
5992
 
5962
- /** Whether an inbox event of this member's is for their AI: a task given to it, or a message to or mentioning "<me>'s AI". */
5993
+ /**
5994
+ * Whether an inbox event is for this person's AI: a task given to it, or a message to or
5995
+ * mentioning "<person>'s AI". Only a cloud AI is that (cloud-ai.js); on a computer, nothing is.
5996
+ */
5963
5997
  forMyAi (e) {
5964
- // A person's cloud AI works for its person's AI, and for itself by name.
5965
- const ai = aiName(this.aiOf || this.name)
5998
+ if (!this.aiOf) return false
5999
+ const ai = aiName(this.aiOf)
5966
6000
  if (e.kind === 'task') return !!e.task && ((e.task.assignee === (this.aiOf || this.name) && !!e.task.forAi) || (!!this.aiOf && e.task.assignee === this.name))
5967
6001
  const m = this.chat.toArray().find((x) => x && x.id === e.id)
5968
6002
  if (!m) return false
@@ -5986,16 +6020,16 @@ export class Session extends EventEmitter {
5986
6020
  /** Whether a name is this member's or one of its AI sessions' (now or before a rename). */
5987
6021
  isMine (name) {
5988
6022
  if (!name) return false
5989
- if (name === this.name || name === aiName(this.name)) return true
5990
- // A person's cloud AI is their AI: what is written to "<person>'s AI" is its to read.
6023
+ if (name === this.name) return true
6024
+ // A person's cloud AI is "<person>'s AI": that name is its own, never its person's computer's.
5991
6025
  if (this.aiOf && name === aiName(this.aiOf)) return true
5992
6026
  for (const p of this.personas.values()) if (p.name === name || p.aliases.includes(name)) return true
5993
6027
  return false
5994
6028
  }
5995
6029
 
5996
- /** Every name this member answers to: its own, "<name>'s AI", and its AI sessions' (now and before a rename). */
6030
+ /** Every name this member answers to: its own (a cloud AI's is "<person>'s AI"), and its AI sessions' (now and before a rename). */
5997
6031
  ownNames () {
5998
- return [this.name, aiName(this.name), ...(this.aiOf ? [aiName(this.aiOf)] : []), ...[...this.personas.values()].flatMap((p) => [p.name, ...p.aliases])]
6032
+ return [this.name, ...(this.aiOf ? [aiName(this.aiOf)] : []), ...[...this.personas.values()].flatMap((p) => [p.name, ...p.aliases])]
5999
6033
  }
6000
6034
 
6001
6035
  /** Whether a claim is this member's or one of its AI sessions': files on this disk are ours to write. */
@@ -6008,14 +6042,13 @@ export class Session extends EventEmitter {
6008
6042
  }
6009
6043
 
6010
6044
  /**
6011
- * How an AI session reads the inbox. What is written to "<person>'s AI" goes to one session
6012
- * only, the one of ours active most recently, so a question is answered once.
6045
+ * How an AI session reads the inbox: by its own name only. What is written to "<person>'s AI"
6046
+ * is the cloud AI's, and work for the person's AI waits on the board until they ask for it.
6013
6047
  */
6014
6048
  personaReader (p) {
6015
- const aliases = p === this.leadPersona() ? [...p.aliases, aiName(this.name)] : p.aliases
6016
6049
  // Its sibling sessions here don't wake it: they work for the same person.
6017
6050
  const own = [...this.personas.values()].filter((q) => q !== p).flatMap((q) => [q.name, ...q.aliases])
6018
- return { name: p.name, aliases, asAi: false, agent: true, of: this.name, own }
6051
+ return { name: p.name, aliases: p.aliases, asAi: false, agent: true, own }
6019
6052
  }
6020
6053
 
6021
6054
  /** The AI session of ours that answers for "<person>'s AI": the live one heard from last, or null. */
@@ -6135,7 +6168,7 @@ export class Session extends EventEmitter {
6135
6168
  const reply = replyFor(visible, { names: this.ownNames(), to, re: re ? String(re).trim() : '' })
6136
6169
  if (reply.error) throw new Error(reply.error)
6137
6170
  to = reply.to
6138
- if (to === by || (to && via && this.persona(via) && to === aiName(this.name))) throw new Error('that is you')
6171
+ if (to === by) throw new Error('that is you')
6139
6172
  const names = agent ? this.memberNames(by) : []
6140
6173
  const targets = agent ? addressees(text, to, names) : []
6141
6174
  if (agent && !file) {
@@ -6160,7 +6193,7 @@ export class Session extends EventEmitter {
6160
6193
  if (via) this.aiSent.push({ via: String(via), targets, text, ts: msg.ts })
6161
6194
  if (agent && text) this.noteActivity(`${to ? `To ${to}` : 'In chat'}: ${text}`, { via, kind: 'reply' })
6162
6195
  this.scheduleStatusWrite()
6163
- const online = !to || this.peerNames().includes(to) || this.status().peers.some((p) => p.persona && p.of && aiName(p.of) === to)
6196
+ const online = !to || this.peerNames().includes(to)
6164
6197
  const away = !online && (this.members || []).some((m) => m.away && m.name === to)
6165
6198
  return { ...this.describeMessage(msg), recipientOnline: online, ...(away ? { recipientAway: true } : {}) }
6166
6199
  }
@@ -6328,7 +6361,9 @@ export class Session extends EventEmitter {
6328
6361
 
6329
6362
  /** Whether a message is meant for me: a well-formed one that is public, mine, or addressed to me. */
6330
6363
  canSee (msg) {
6331
- return validMessage(msg) && (!msg.to || this.isMine(msg.to) || this.isMine(msg.by))
6364
+ // A person also sees what is written to and by their cloud AI ("<me>'s AI"): it is theirs.
6365
+ const mineOrMyAi = (n) => this.isMine(n) || (this.kind !== 'agent' && n === aiName(this.name))
6366
+ return validMessage(msg) && (!msg.to || mineOrMyAi(msg.to) || mineOrMyAi(msg.by))
6332
6367
  }
6333
6368
 
6334
6369
  peerNames () {
package/src/setup.js CHANGED
@@ -39,10 +39,10 @@ ${TASK_WORKFLOW_MD}
39
39
  AI sessions, named after your work: "<their first name> · <label>", from your git
40
40
  branch or the first thing you say you're doing. Rename yourself with
41
41
  \`quilt_name_session\`. Your messages, inbox, claims and duties are your own. People
42
- see all of a person's AI sessions as one, "<person>'s AI": write to another person's AI
43
- by that name ("@Daniel's AI"), and what is written to your person's AI reaches whichever
44
- of their sessions was active last (with none of them live, their cloud AI, if they
45
- connected one: their own AI tool on their own cloud VM).
42
+ see each AI session by its own name. "<person>'s AI" ("@Daniel's AI") is only that
43
+ person's cloud AI, when they connected one (their own AI tool on their own cloud
44
+ machine). Nothing written or handed to "<person>'s AI" comes to you; when your person
45
+ asks you to work on tasks, take them from the board (\`quilt_tasks\`).
46
46
  - Claims follow edits, whatever tool you are: the moment you change a file nobody
47
47
  holds, Quilt claims it for you, and lets go when you finish (your AI goes idle, or
48
48
  the file has been quiet for a few minutes). Claim ahead only for a larger change
@@ -73,7 +73,7 @@ export function agentGuide ({ apiUrl = 'https://api.heyquilt.com', via = 'both'
73
73
  '- `quilt_tasks` lists the board (To do, In progress, QA, Done) with ids; open tasks assigned to you come first.',
74
74
  '- `quilt_task` reads one task in full with its comments; `quilt_comment_task` leaves a work note, a handoff or the reason for an assignment on it, instead of in the chat.',
75
75
  '- Keep notes on tickets brief, the same as chat: comments, qaNotes and verified say in a few short sentences what changed, why, and how you checked it. Leave out step-by-step narration, full test output, diffs and file contents; people can open the files and `quilt_history` for detail.',
76
- `- Add: ${call('quilt_add_task', { title: 'a few words', assignee: '<name> or me', files: ['src/app.js'] })}. Assign: ${call('quilt_assign_task', { id: '<task id>', assignee: '<name>', to_ai: true })}. to_ai: true gives it to that person's AI instead of the person; an agent is assigned by its own name, without to_ai.`,
76
+ `- Add: ${call('quilt_add_task', { title: 'a few words', assignee: '<name> or me', files: ['src/app.js'] })}. Assign: ${call('quilt_assign_task', { id: '<task id>', assignee: '<name>', to_ai: true })}. to_ai: true gives it to that person's AI (their cloud AI, when they connected one) instead of the person; an agent or an AI session is assigned by its own name, without to_ai.`,
77
77
  `- Move: ${call('quilt_move_task', { id: '<task id>', column: 'doing' })} when you start (you get a briefing: its files, recent changes, claims, the project's checks); "qa" when done and tested, with qaNotes (what changed, how you checked it); "done" after QA, with verified (what you ran and what you saw). A move without those is refused.`,
78
78
  '- A task handed to you wakes you like a direct message. Take it with quilt_move_task, or say in chat why not.',
79
79
  '- If you change files for something not on the board, Quilt adds an In progress task for it: use that one rather than adding another, and move it on when you finish. `quilt_archive_task` takes a task off the board but keeps it (archived false brings it back); `quilt_delete_task` removes it for good.',
package/src/ui/app.css CHANGED
@@ -2170,16 +2170,6 @@ label.radio, .field label.radio { display: flex; align-items: center; gap: 8px;
2170
2170
  label.radio input { margin: 0; }
2171
2171
  label.radio:has(input:disabled) { cursor: default; opacity: .75; }
2172
2172
 
2173
- /* When work comes to your AI (Settings → Your AI): today's usage and the latest runs. */
2174
- #auto-usage { border-top: 1px solid var(--border); margin-top: 6px; padding-top: 6px; }
2175
- #auto-usage:empty { display: none; }
2176
- .auto-usage-head { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 10px 0 2px; }
2177
- .auto-runs { display: flex; flex-direction: column; }
2178
- .auto-run { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); }
2179
- .auto-run:last-child { border-bottom: 0; }
2180
- .auto-run .nm { display: flex; flex-direction: column; min-width: 0; flex: 1; }
2181
- .auto-run .nm b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2182
-
2183
2173
  /* One set of AI rules (session ⋯ menu): what gathering into AGENTS.md changes, file by file. */
2184
2174
  .gather-rules { width: min(760px, 100%); max-height: min(86vh, 900px); padding: 0; display: flex; flex-direction: column; overflow: hidden; }
2185
2175
  .gr-body { min-height: 0; overflow-y: auto; padding: 16px 24px 20px; }
@@ -2199,6 +2189,22 @@ label.radio:has(input:disabled) { cursor: default; opacity: .75; }
2199
2189
  .branch-menu .br-compact .br-sync { flex: none; margin: 0; height: 24px; padding: 0 8px; gap: 4px; font-size: 12px; }
2200
2190
  .branch-menu .br-compact .br-sync svg { width: 12px; height: 12px; color: inherit; }
2201
2191
  .cp-line { display: flex; align-items: center; gap: 8px; margin: 0 0 8px; font-size: 12.5px; min-width: 0; }
2192
+ /* Your work not committed (session.js mineHtml): two lines, boxed and shared, each opening to its files. */
2193
+ .mw-head { display: flex; align-items: baseline; justify-content: space-between; }
2194
+ .mw-head h4 { margin-top: 0; }
2195
+ .mw-none { margin: 0 2px 4px; font-size: 12.5px; color: var(--muted); }
2196
+ details.mw { margin: 0 0 4px; }
2197
+ details.mw summary { list-style: none; cursor: pointer; display: flex; align-items: center; gap: 8px; min-height: 30px; padding: 0 4px; font-size: 13px; border-radius: 4px; }
2198
+ details.mw summary::-webkit-details-marker { display: none; }
2199
+ details.mw summary:hover { background: var(--panel-2); }
2200
+ details.mw summary .hint { font-size: 12.5px; }
2201
+ .mw-ico { width: 14px; height: 14px; display: grid; place-items: center; flex: none; }
2202
+ .mw-ico svg { width: 14px; height: 14px; color: var(--box, var(--warn)); }
2203
+ .mw-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--edit); }
2204
+ details.mw ul { list-style: none; margin: 0 0 6px; padding: 0 0 0 26px; }
2205
+ details.mw li { min-height: 26px; display: flex; align-items: center; min-width: 0; }
2206
+ details.mw li code { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
2207
+ details.mw + .cp-commit, .mw-head + .cp-commit { margin-top: 8px; }
2202
2208
  .cp-line:last-child { margin-bottom: 0; }
2203
2209
  .cp-line > span:not(.sd) { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2204
2210
  .cp-line .sd { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); flex: none; }
package/src/ui/chat.js CHANGED
@@ -136,33 +136,19 @@ export function relayPrompts (text, { owner = false } = {}) {
136
136
  return RELAY_QUESTIONS.filter((q) => owner || !q.owner)
137
137
  }
138
138
 
139
- // One name for all of a person's AI sessions. People see "Daniel's AI" rather than each chat
140
- // ("Daniel · file-queue", "Daniel · Claude Code 3"), and can write to it: the session of theirs
141
- // active most recently answers (src/session.js leadPersona).
139
+ // "<person>'s AI" is a person's cloud AI (src/cloud-ai.js), and only that: their AI sessions on
140
+ // computers are shown by their own names ("Daniel · file-queue", "Daniel · Claude Code 3").
142
141
  export const AI_SUFFIX = '\'s AI'
143
142
 
144
- /** "Daniel's AI": every AI session working through Daniel's app, as one name. */
143
+ /** "Daniel's AI": Daniel's cloud AI's name. */
145
144
  export const aiName = (person) => `${String(person || '').trim()}${AI_SUFFIX}`
146
145
 
147
146
  /**
148
- * Peers as people see them: the AI sessions of each person (persona: true, of: person) folded
149
- * into one "<person>'s AI" entry listing them under `sessions`. Order is kept (first seen wins).
147
+ * Peers as people see them: each AI session as itself (by its own name, kind agent), with whose
148
+ * it is (`aiOf`). Nothing is folded into one "<person>'s AI": that name is only a cloud AI's.
150
149
  */
151
150
  export function foldPersonas (peers) {
152
- const out = []
153
- const groups = new Map()
154
- for (const p of peers || []) {
155
- if (!p || !p.persona || !p.of) { out.push(p); continue }
156
- let g = groups.get(p.of)
157
- if (!g) {
158
- g = { name: aiName(p.of), kind: 'agent', aiOf: p.of, ...(p.mine ? { mine: true } : {}), tool: '', agents: [], focus: '', editing: [], sessions: [] }
159
- groups.set(p.of, g)
160
- out.push(g)
161
- }
162
- g.sessions.push({ name: p.name, tool: p.tool || '', focus: p.focus || '' })
163
- if (p.tool && !g.agents.includes(p.tool)) g.agents.push(p.tool)
164
- }
165
- return out
151
+ return (peers || []).map((p) => (p && p.persona && p.of ? { ...p, kind: 'agent', aiOf: p.of } : p))
166
152
  }
167
153
 
168
154
  const PERSONA_SEP = ' · ' // src/persona.js: "Daniel · file-queue"
@@ -195,8 +181,8 @@ export function aiOwners ({ peers = [], messages = [], people = [], names = [] }
195
181
  return owners
196
182
  }
197
183
 
198
- /** A name as people see it: one of someone's AI sessions is "<person>'s AI". */
199
- export const shownName = (name, owners) => owners && owners.has(name) ? aiName(owners.get(name)) : name
184
+ /** A name as people see it: its own (an AI session is never shown as "<person>'s AI"). */
185
+ export const shownName = (name) => name
200
186
 
201
187
  /**
202
188
  * True when a message is one of a person's AI sessions writing to another of their own (by
@@ -20,7 +20,7 @@ export function cloudAiCardHtml () {
20
20
  const pill = (s) => `<span class="pill cai-pill ${s.tone}"><span class="dot"></span>${esc(s.label)}</span>`
21
21
 
22
22
  function connectHtml (ui) {
23
- return `<div class="sec-intro"><h2>${TITLE}</h2><p>Your own AI tool, signed in with your own account, on a machine of yours that is always there. It answers for your AI when none of your AI sessions is open.</p></div>
23
+ return `<div class="sec-intro"><h2>${TITLE}</h2><p>Your own AI tool, signed in with your own account, on a machine of yours that is always there. It becomes "your AI": what anyone writes or hands to your AI goes to it.</p></div>
24
24
  <div class="sec-body cai-body">
25
25
  <div class="cai-tools">${CLOUD_TOOLS.map((t, i) => `<div class="cai-tool">
26
26
  ${toolLogo(t.tool, { className: 'cai-logo' })}
@@ -76,16 +76,15 @@ function signInHtml (vm, ui) {
76
76
  function readyHtml (vm, ui) {
77
77
  const st = cloudStatus(vm)
78
78
  const u = cloudUsage(vm)
79
- const me = state.profile && state.profile.name ? state.profile.name.split(/\s+/)[0] : 'your'
80
79
  const online = vm.state === 'running' ? true : vm.state === 'suspended' ? 'away' : false
81
80
  const sleeping = ['suspended', 'stopped', 'failed'].includes(vm.state)
82
81
  const sessions = (vm.sessions || []).length
83
82
  ? `<div class="cai-sessions">${vm.sessions.map((s) => `<div class="cai-sess"><span class="grow"><b>${esc(s.name || s.room)}</b></span><span class="hint">joined</span></div>`).join('')}</div>`
84
83
  : '<p class="hint">In no session yet. It joins the sessions you join, and stays when you leave for the day.</p>'
85
- return `<div class="sec-intro"><h2>${TITLE}</h2><p>It answers for your AI when none of your AI sessions is open: tasks, @mentions and messages, while your computer is closed.</p></div>
84
+ return `<div class="sec-intro"><h2>${TITLE}</h2><p>This is your AI: tasks, @mentions and messages for it come here, even while your computer is closed. Your AI sessions on your computer keep their own names.</p></div>
86
85
  <div class="sec-body cai-body">
87
86
  <div class="cai-who">${avatar(vm.name || 'AI', state.profile && state.profile.color, online)}
88
- <div class="grow"><b>${esc(vm.name || 'Cloud AI')}</b><span class="hint">${esc(vm.tool)} on your cloud machine · one of ${esc(me === 'your' ? 'your' : `${me}'s`)} AI</span></div>
87
+ <div class="grow"><b>${esc(vm.name || 'Cloud AI')}</b><span class="hint">${esc(vm.tool)} on your cloud machine</span></div>
89
88
  ${pill(st)}
90
89
  ${sleeping ? `<button class="btn sm" type="button" data-cloud-wake ${ui.busy === 'wake' ? 'disabled' : ''}>${vm.state === 'suspended' ? 'Wake now' : 'Start it again'}</button>` : ''}
91
90
  </div>
@@ -98,7 +97,7 @@ function readyHtml (vm, ui) {
98
97
  <div><b>${u.pct === null ? 'No cap' : `${u.pct}%`}</b><span>${u.pct === null ? 'set a daily cap below' : `of ${esc(u.cap)} daily cap`}</span></div>
99
98
  </div>
100
99
  ${u.pct === null ? '' : `<div class="cai-meter${u.over ? ' over' : ''}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${u.pct}" aria-label="Today's use of the daily cap"><i style="width:${u.pct}%"></i></div>`}
101
- ${u.over ? '<p class="hint warn">The daily cap is reached: work waits for your chat until tomorrow.</p>' : ''}
100
+ ${u.over ? '<p class="hint warn">The daily cap is reached: work for your AI waits on the board until tomorrow.</p>' : ''}
102
101
  <hr class="cai-sep">
103
102
  <form class="cai-form" id="cloud-settings-form" autocomplete="off">
104
103
  <div class="cai-two">
package/src/ui/home.js CHANGED
@@ -787,76 +787,12 @@ function workspacesSettingsHtml () {
787
787
  </section>`
788
788
  }
789
789
 
790
- const AUTO_CHOICES = [['chat', 'Leave it for my chat'], ['open', 'Open it in my tool'], ['auto', 'Work on its own']]
791
-
792
- /** "When work comes to my AI" (src/autowork.js): leave it, open it, or let the AI work on its own. */
793
- function autoSettingsHtml (p) {
794
- const a = p.auto || { mode: 'chat', supported: false, models: [], model: '', changeFiles: false, limitsFiles: true, cap: 0, tool: p.tool }
795
- const tool = esc(a.tool || p.tool || 'your AI tool')
796
- return `<form class="card settings-sec" id="auto-sec" autocomplete="off">
797
- <div class="sec-intro"><h2>When work comes to your AI</h2><p>A task given to your AI, or a merge sent to it, while none of your AI sessions is open.</p></div>
798
- <div class="sec-body">
799
- <div class="field">
800
- <label for="s-autowork">Do this</label>
801
- <select class="input" id="s-autowork" name="autoWork">${AUTO_CHOICES.map(([v, l]) => {
802
- const off = v === 'auto' && !a.supported && a.mode !== 'auto'
803
- return `<option value="${v}" ${a.mode === v ? 'selected' : ''} ${off ? 'disabled' : ''}>${l}${off ? ` (not available for ${tool})` : ''}</option>`
804
- }).join('')}</select>
805
- <span class="hint" id="s-autowork-hint">${autoHint(a.mode, tool)}</span>
806
- </div>
807
- <div class="auto-only" ${a.mode === 'auto' ? '' : 'hidden'}>
808
- ${a.supported ? '' : `<p class="error">${tool} can't work on its own. Pick Claude Code, Cursor, Codex or GitHub Copilot in your profile, or choose another option.</p>`}
809
- <div class="field">
810
- <label for="s-automodel">Model</label>
811
- <input class="input" id="s-automodel" name="autoModel" list="s-automodels" value="${esc(a.model || '')}" placeholder="${esc(a.models[0] || '')}">
812
- <datalist id="s-automodels">${a.models.map((m) => `<option value="${esc(m)}">`).join('')}</datalist>
813
- <span class="hint">${a.models.length ? `Suggested: ${a.models.map(esc).join(', ')}. ${esc(a.models[0])} is the default; type any model ${tool} knows.` : 'The model your tool uses by default.'}</span>
814
- </div>
815
- ${toggle('autoFiles', a.changeFiles, 'Let it change files', a.limitsFiles ? 'Off: it reads, plans, comments and asks, and leaves changes to you. File claims apply either way.' : `${tool} can change files either way; this also lets it run commands. File claims apply either way.`)}
816
- <div class="field">
817
- <label for="s-autocap">Daily limit (tokens)</label>
818
- <input class="input" id="s-autocap" name="autoCap" type="number" min="0" step="1000" value="${a.cap || ''}" placeholder="No limit">
819
- <span class="hint">Past it, work waits for your chat until tomorrow, and you're told once.</span>
820
- </div>
821
- </div>
822
- <div class="sec-actions"><span><span class="hint auto-only" ${a.mode === 'auto' ? '' : 'hidden'}>Runs on this computer, with your own ${tool} account, while Quilt is open. It asks questions in Quilt chat; a reply carries the same chat on.</span></span><button class="btn primary" type="submit">Save</button></div>
823
- <div id="auto-usage"></div>
824
- </div>
825
- </form>`
826
- }
827
-
828
- function autoHint (mode, tool) {
829
- if (mode === 'auto') return `${tool} starts on the work by itself, in the session's folder. You see what it does in the feed and its questions in chat.`
830
- if (mode === 'open') return `${tool} opens on the work in the session's folder, for you to drive.`
831
- return 'Nothing opens: your AI picks it up from its inbox the next time you work with it.'
832
- }
833
-
834
- /** Today's runs and tokens, and the latest runs with a way to carry each chat on. */
835
- function autoUsageHtml (u) {
836
- const tools = Object.entries(u.today || {})
837
- const n = (x) => Number(x || 0).toLocaleString()
838
- const today = tools.length
839
- ? tools.map(([t, v]) => `<div class="kv"><span>${esc(t)}</span><b>${n(v.input + v.output)} tokens</b><span class="hint">${v.runs} run${v.runs === 1 ? '' : 's'} today${v.costUsd ? ` · $${v.costUsd.toFixed(2)}` : ''}</span></div>`).join('')
840
- : '<p class="hint">No runs today.</p>'
841
- const runs = (u.runs || []).slice(0, 8).map((r) => `<div class="auto-run">
842
- <span class="nm"><b>${esc(r.title || r.kind)}</b><span class="hint">${esc(r.tool)} · ${r.reported ? `${n(r.input + r.output)} tokens` : 'tokens not reported'} · ${r.outcome === 'ok' ? 'finished' : 'failed'} · ${ago(r.ts)}</span></span>
843
- ${r.chatId ? `<button class="btn sm" type="button" data-take-over="${esc(r.run)}">Open chat</button>` : ''}
844
- </div>`).join('')
845
- return `<h3 class="auto-usage-head">Today</h3>${today}${runs ? `<h3 class="auto-usage-head">Latest runs</h3><div class="auto-runs">${runs}</div>` : ''}`
846
- }
847
-
848
- async function loadAutoUsage (root) {
849
- const box = $('#auto-usage', root)
850
- if (!box) return
851
- try { box.innerHTML = autoUsageHtml(await api('GET', '/api/autowork')) } catch (err) { box.innerHTML = `<p class="error">${esc(err.message)}</p>` }
852
- }
853
-
854
790
  function settingsHtml ({ head = true } = {}) {
855
791
  const p = state.profile
856
792
  const a = state.account || { name: p.name, email: '' }
857
793
  const theme = p.theme || document.documentElement.dataset.theme || 'dark'
858
794
  const tab = settingsTab()
859
- const show = (id) => ({ account: ['account', 'about', 'computer'], you: ['profile', 'appearance', 'sounds'], sessions: ['sessions'], agents: ['your-ai', 'auto', 'cloud-ai', 'agents', 'agents-git'], workspaces: ['workspaces'] })[tab].includes(id)
795
+ const show = (id) => ({ account: ['account', 'about', 'computer'], you: ['profile', 'appearance', 'sounds'], sessions: ['sessions'], agents: ['your-ai', 'cloud-ai', 'agents', 'agents-git'], workspaces: ['workspaces'] })[tab].includes(id)
860
796
  return `
861
797
  ${head ? `<header class="page-head">
862
798
  <h1>Settings</h1>
@@ -886,7 +822,6 @@ function settingsHtml ({ head = true } = {}) {
886
822
  </div>
887
823
  </form>` : ''}
888
824
 
889
- ${show('auto') ? autoSettingsHtml(p) : ''}
890
825
 
891
826
  ${show('cloud-ai') ? cloudAiCardHtml() : ''}
892
827
 
@@ -1212,27 +1147,6 @@ function bindSettings (root, refresh) {
1212
1147
  const ai = $('#your-ai-sec', root)
1213
1148
  if (ai) saveForm(ai, (f) => ({ shareAgent: !!f.get('shareAgent'), summarize: !!f.get('summarize'), aiTasks: f.get('aiTasks') }))
1214
1149
 
1215
- const auto = $('#auto-sec', root)
1216
- if (auto) {
1217
- saveForm(auto, (f) => ({ autoWork: f.get('autoWork'), ...(f.get('autoWork') === 'auto' ? { autoModel: f.get('autoModel'), autoFiles: !!f.get('autoFiles'), autoCap: f.get('autoCap') } : {}) }))
1218
- const sel = $('#s-autowork', auto)
1219
- sel.onchange = () => {
1220
- for (const el of auto.querySelectorAll('.auto-only')) el.hidden = sel.value !== 'auto'
1221
- $('#s-autowork-hint', auto).textContent = autoHint(sel.value, state.profile.auto?.tool || state.profile.tool)
1222
- }
1223
- auto.addEventListener('click', async (e) => {
1224
- const b = e.target.closest('[data-take-over]')
1225
- if (!b) return
1226
- b.disabled = true
1227
- try {
1228
- const r = await api('POST', '/api/autowork/take-over', { run: b.dataset.takeOver })
1229
- toast(r.opened ? 'Opened the chat in your AI tool' : r.copied ? `Copied: ${r.command}. Paste it in a terminal.` : r.command)
1230
- } catch (err) { toast(err.message) }
1231
- b.disabled = false
1232
- })
1233
- loadAutoUsage(root)
1234
- }
1235
-
1236
1150
  const cloud = $('#cloud-ai-sec', root)
1237
1151
  if (cloud) bindCloudAi(cloud)
1238
1152
 
@@ -6,7 +6,6 @@
6
6
  // thin strips (and past a few strips, into one "crowd" column) where each thing
7
7
  // they did is a knot in the same rows. Pure, so it can be tested without a browser.
8
8
  import { conversations } from './feed-convs.js'
9
- import { aiName } from './chat.js'
10
9
 
11
10
  export const GAP_MS = 20 * 60 * 1000 // a pause this long between two things gets a "later" divider
12
11
  export const TURN_GAP_MS = 10 * 60 * 1000 // a conversation quiet this long starts a new turn without a prompt
@@ -270,13 +269,13 @@ function dayOf (ts) {
270
269
  }
271
270
 
272
271
  /**
273
- * One lane per person's AI, as chat shows it: every AI session of a person ("Daniel · Claude
274
- * Code", "Daniel · Claude Code 2", ones gone since) becomes "Daniel's AI". `owners`: AI session
275
- * name -> its person (chat.js aiOwners). Returns the buildLoom inputs with those names folded,
276
- * and `personOf`: lane name -> the person whose AI chat it opens.
272
+ * The Loom's lanes, as chat shows them: each AI session is a lane of its own ("Daniel · Claude
273
+ * Code", "Daniel · file-queue"); none is folded into "<person>'s AI". `owners`: AI session name
274
+ * -> its person (chat.js aiOwners). Returns the buildLoom inputs and `personOf`: lane name -> the
275
+ * person whose AI it is.
277
276
  */
278
277
  export function foldAiLanes ({ people = [], owners = new Map(), feeds = new Map(), messages = [], tasks = [], claims = [] } = {}) {
279
- const lane = (name) => owners.has(name) ? aiName(owners.get(name)) : name
278
+ const lane = (name) => name
280
279
  const personOf = new Map()
281
280
  const out = []
282
281
  const groups = new Map()
@@ -297,14 +296,14 @@ export function foldAiLanes ({ people = [], owners = new Map(), feeds = new Map(
297
296
  personOf.set(name, owners.get(p.name))
298
297
  out.push(folded)
299
298
  }
300
- // An AI session that is gone still has its chat and notes: they go to its person's AI lane.
301
- for (const person of new Set(owners.values())) {
302
- const name = aiName(person)
303
- if (groups.has(name)) continue
299
+ // An AI session that is gone still has its chat and notes: it keeps a lane of its own for them.
300
+ for (const [name, person] of owners) {
301
+ if (groups.has(name) || out.some((p) => p && p.name === name)) continue
304
302
  groups.set(name, true)
305
303
  personOf.set(name, person)
306
304
  out.push({ name, kind: 'agent', tool: '', online: false, optional: true, agent: null })
307
305
  }
306
+
308
307
  const folded = new Map()
309
308
  for (const [name, list] of feeds) {
310
309
  const key = lane(name)
@@ -12,15 +12,16 @@ const claimFolder = (pattern) => String(pattern).replace(/\/\*\*$/, '').replace(
12
12
  /**
13
13
  * Everything the rail and its lock list show. `claims`: the session's ({ pattern, by, ts, note,
14
14
  * active, activeAt, queue }); `me`: your name;
15
- * `owners`: AI session name -> its person (chat.js aiOwners), so a person's AI sessions are one
16
- * holder, "<person>'s AI"; `aiName`: person -> that name; `files`: the session's paths (to count a
15
+ * `owners`: AI session name -> its person (chat.js aiOwners); each AI session holds its claims
16
+ * under its own name; `aiName` is unused (kept for callers); `files`: the session's paths (to count a
17
17
  * folder's); `focusOf`: name -> what they
18
18
  * said they're doing (a lock's why when it has no note); `kindOf`: name -> 'agent' for an AI agent.
19
19
  * Returns { locks, holders, waiting, mine }, locks oldest first.
20
20
  */
21
21
  export function railModel ({ claims = [], me = '', owners = new Map(), aiName = (p) => `${p}'s AI`, files = [], focusOf = () => '', kindOf = () => '', now = Date.now(), idleMs = CLAIM_IDLE_MS } = {}) {
22
22
  const fileSet = new Set(files)
23
- const holderOf = (by) => owners.has(by) ? aiName(owners.get(by)) : by
23
+ // Each AI session holds its claims under its own name.
24
+ const holderOf = (by) => by
24
25
  const locks = (claims || []).filter((c) => c && c.pattern && c.by).map((c) => {
25
26
  const bare = claimFolder(c.pattern)
26
27
  // "src/auth", "src/auth/" and "src/auth/**" all claim the folder (tree.js claimFolder).
package/src/ui/session.js CHANGED
@@ -199,6 +199,7 @@ export function mountSession (id) {
199
199
  bindTop()
200
200
  bindAccess()
201
201
  bindChanges(id, mounted.signal, { onOpen: (path) => openFile(path, { tab: 'changes' }), close: () => setGitOpen(false), onCount: renderGitChip })
202
+ loadMine(true)
202
203
  bindCatchUp($('#catchup'), { sessionId: () => current, onOpen: openFile })
203
204
  bindGithubCard()
204
205
  bindMain()
@@ -263,6 +264,7 @@ export function sessionUpdated (id) {
263
264
  if (ws(id).mode === 'tasks') paintBoard()
264
265
  scheduleTree()
265
266
  changesChanged()
267
+ loadMine()
266
268
  }
267
269
 
268
270
  export function sessionMessage (id) {
@@ -299,6 +301,7 @@ export function sessionFileChanged (id, { path }) {
299
301
  if (id !== current) return
300
302
  scheduleTree()
301
303
  changesChanged()
304
+ loadMine()
302
305
  const w = ws(id)
303
306
  if (w.fileTabs.includes(path)) {
304
307
  if (w.mode === 'files' && w.fileSel === path) { refreshFile(path, true); scheduleHistory(path) } else { w.stale[path] = true; histories.delete(fileKey(path)); renderMainBar() }
@@ -681,6 +684,33 @@ function renderTop () {
681
684
  // request has a Commit button (exactly its files, then a push). "Not committed yet" shows the
682
685
  // uncommitted work here by who made it, so nothing finished sits unshipped unnoticed.
683
686
  let commitGithub = null // the owner's GitHub connection (GET /api/github), while the panel is open
687
+ // Your own work not committed here (GET mine-uncommitted: yours and your AIs', each boxed or not),
688
+ // for the git chip's count and the panel. Nobody else's: they commit their own.
689
+ let mineView = { id: null, data: null, at: 0, loading: false, again: false, open: new Set(['boxed']) }
690
+ const MINE_FRESH_MS = 3000 // fetched again at most this often as the session changes
691
+
692
+ function loadMine (force = false) {
693
+ const id = current
694
+ if (!id) return
695
+ if (mineView.id !== id) mineView = { id, data: null, at: 0, loading: false, again: false, open: new Set(['boxed']) }
696
+ if (mineView.loading) { mineView.again = true; return }
697
+ const wait = MINE_FRESH_MS - (Date.now() - mineView.at)
698
+ if (!force && wait > 0) { clearTimeout(loadMine.timer); loadMine.timer = setTimeout(() => loadMine(true), wait); return }
699
+ mineView.loading = true
700
+ api('GET', `/api/sessions/${id}/mine-uncommitted`).then((r) => {
701
+ if (mineView.id !== id) return
702
+ mineView.data = r && Array.isArray(r.files) ? r : null
703
+ mineView.at = Date.now()
704
+ renderGitChip()
705
+ }, () => {}).finally(() => {
706
+ if (mineView.id !== id) return
707
+ mineView.loading = false
708
+ if (mineView.again) { mineView.again = false; loadMine() }
709
+ })
710
+ }
711
+
712
+ /** How many of your own files aren't committed here (null before the first look, or without git). */
713
+ const mineCount = () => (mineView.id === current && mineView.data ? mineView.data.files.length : null)
684
714
  /** Session settings: GitHub sync, large files through the relay, and git for this session. */
685
715
  async function gitSettingChange (e) {
686
716
  if (e.target.matches('[data-large-to-relay]')) {
@@ -712,7 +742,7 @@ function setGitOpen (open) {
712
742
  if (!panel) return
713
743
  panel.hidden = !open
714
744
  $('#git-chip').setAttribute('aria-expanded', String(open))
715
- if (open) { renderBranchMenu(); renderCommitPanel(); loadCommitWork(); loadChanges() }
745
+ if (open) { renderBranchMenu(); renderCommitPanel(); loadCommitWork(); loadChanges(); loadMine(true) }
716
746
  }
717
747
 
718
748
  function bindCommitChip () {
@@ -731,14 +761,20 @@ function bindCommitChip () {
731
761
  btn.disabled = true
732
762
  btn.textContent = 'Committing…'
733
763
  try {
734
- const r = await api('POST', `/api/sessions/${current}/commit-session`, { message: input.value })
764
+ const r = await api('POST', `/api/sessions/${current}/commit-session`, { message: input.value, mine: true })
735
765
  toast(r.hash ? `Committed ${r.hash.slice(0, 7)}${r.pushed ? ' and pushed' : `; not pushed: ${r.pushError || 'no upstream'}`}` : 'Already in git')
736
766
  input.value = ''
767
+ loadMine(true)
737
768
  } catch (err) { toast(err.message) } finally {
738
769
  btn.disabled = false
739
770
  btn.textContent = 'Commit and push'
740
771
  }
741
772
  })
773
+ // Which of your two lines (boxed, shared) is open stays so across redraws.
774
+ panel.addEventListener('toggle', (e) => {
775
+ const d = e.target.closest && e.target.closest('[data-mine-group]')
776
+ if (d) d.open ? mineView.open.add(d.dataset.mineGroup) : mineView.open.delete(d.dataset.mineGroup)
777
+ }, true)
742
778
  panel.addEventListener('click', async (e) => {
743
779
  if (e.target.closest('[data-gh-connect]')) { connectGithub(); return }
744
780
  const live = e.target.closest('[data-open-live]')
@@ -796,17 +832,19 @@ function renderGitChip () {
796
832
  const dot = $('#git-dot')
797
833
  dot.hidden = !!say || !(live && live.dot)
798
834
  dot.className = `sd${live && live.dot ? ` ${live.dot}` : ''}`
799
- const n = changedCount()
835
+ const own = mineCount()
836
+ const n = own === null ? changedCount() : own
800
837
  const branch = $('#git-branch')
838
+ $('#chg-count').textContent = n
801
839
  $('#git-chg').hidden = !n
802
840
  $('#git-sep').hidden = branch.hidden || !n
803
841
  const lines = [
804
842
  branch.hidden ? '' : branch.dataset.title,
805
- n ? `${n} file${n === 1 ? '' : 's'} changed in this session.` : '',
843
+ n ? own === null ? `${n} file${n === 1 ? '' : 's'} changed in this session.` : `${n} file${n === 1 ? '' : 's'} of yours not committed.` : '',
806
844
  open.length ? open.map((r) => `${r.by}: ${r.message}`).join('\n') : live ? live.line : gh ? githubWords(gh) : ''
807
845
  ].filter(Boolean)
808
846
  chip.title = [...lines, 'Click for branches, GitHub and every change.'].join('\n')
809
- chip.setAttribute('aria-label', `Source control${branch.hidden ? '' : `: ${st.git.key}`}${n ? `, ${n} changed` : ''}${say ? `, ${say}` : live ? `, ${live.chip}` : ''}`)
847
+ chip.setAttribute('aria-label', `Source control${branch.hidden ? '' : `: ${st.git.key}`}${n ? own === null ? `, ${n} changed` : `, ${n} of yours not committed` : ''}${say ? `, ${say}` : live ? `, ${live.chip}` : ''}`)
810
848
  if (!$('#git-panel').hidden) renderCommitPanel()
811
849
  }
812
850
 
@@ -829,6 +867,7 @@ function renderCommitPanel () {
829
867
  // (sync, large files, what agents may commit) live in Session settings.
830
868
  const html = `
831
869
  ${open.length ? `<h4>Asked to be committed</h4><ul class="commit-reqs">${open.map(req).join('')}</ul>` : ''}
870
+ ${mineHtml(st)}
832
871
  ${gitLineHtml(st)}
833
872
  ${connectHtml(st)}
834
873
  ${open.length > 1 ? '<div class="commit-foot"><button type="button" class="btn sm ghost" data-done="all">Close all</button></div>' : ''}`.trim()
@@ -873,20 +912,33 @@ function syncWords (st) {
873
912
  return { chip: 'Not syncing', line: `${(s.why || `sync of ${name} with GitHub is off`).replace(/^./, (c) => c.toUpperCase())}.`, warn: false, off: true }
874
913
  }
875
914
 
915
+ /**
916
+ * Your own work not committed here, in two lines (mockup option B): what is boxed (in your folder
917
+ * only so far) and what everyone already has; each opens to its files. Then the commit, which
918
+ * takes only these. Nobody else's work is counted or listed.
919
+ */
920
+ function mineHtml (st) {
921
+ const files = mineView.id === current && mineView.data ? mineView.data.files : null
922
+ if (!files) return ''
923
+ if (!files.length) return '<div class="mw-head"><h4>Not committed</h4></div><p class="mw-none">Nothing of yours: your work here is all in git.</p>'
924
+ const boxed = files.filter((f) => f.boxed)
925
+ const shared = files.filter((f) => !f.boxed)
926
+ const group = (key, list, ico, label, hint) => list.length
927
+ ? `<details class="mw" data-mine-group="${key}"${mineView.open.has(key) ? ' open' : ''}><summary><span class="mw-ico">${ico}</span><span>${list.length} ${label}</span><span class="hint">${hint}</span></summary><ul>${list.map((f) => `<li><code title="${esc(f.path)}">${esc(f.path)}</code></li>`).join('')}</ul></details>`
928
+ : ''
929
+ return `<div class="mw-head"><h4>Not committed</h4></div>
930
+ ${group('boxed', boxed, I.box, 'boxed', 'only in your folder')}
931
+ ${group('shared', shared, '<span class="mw-dot"></span>', 'shared', 'everyone has them')}
932
+ ${st.canCommit ? `<form class="cp-commit" data-commit-session><input class="input" name="message" placeholder="Commit message" aria-label="Commit message" maxlength="500" required autocomplete="off"><button type="submit" class="btn sm primary">Commit and push</button></form>` : ''}`
933
+ }
934
+
876
935
  /** The session against GitHub in a line (the long words on hover), and the commit that sends what isn't there yet. */
877
936
  function gitLineHtml (st) {
878
937
  const w = syncWords(st)
879
938
  if (!w || w.off) return ''
880
939
  const b = myBranchOf(st)
881
- if (w.pending) {
882
- const s = b.relay.sync
883
- const n = s.pending || 0
884
- const gh = githubStateOf(st)
885
- const sha = s.revision ? s.revision.slice(0, 7) : ''
886
- const commit = !sha ? '' : gh ? `<a href="https://github.com/${esc(gh.repo)}/commit/${esc(s.revision)}" target="_blank" rel="noreferrer">${sha}</a>` : `<code>${sha}</code>`
887
- return `<p class="cp-line" title="${esc(w.line)}"><span class="sd edit"></span><span>${n} file${n === 1 ? '' : 's'} not committed${sha ? ` · GitHub at ${commit}` : ''}</span></p>
888
- ${st.canCommit ? `<form class="cp-commit" data-commit-session><input class="input" name="message" placeholder="Commit message" aria-label="Commit message" maxlength="500" required autocomplete="off"><button type="submit" class="btn sm primary">Commit and push</button></form>` : ''}`
889
- }
940
+ // Work not on GitHub yet: what is yours shows above (mineHtml); everyone else's is theirs to commit.
941
+ if (w.pending) return ''
890
942
  const pr = w.pr ? (w.pr.url ? ` · <a href="${esc(w.pr.url)}" target="_blank" rel="noreferrer">pull request #${w.pr.number}</a>` : '') : ''
891
943
  const live = w.liveIn ? ` <button type="button" class="btn sm" data-open-live="${esc(w.liveIn)}">Open the live session</button>` : ''
892
944
  return `<p class="cp-line${w.warn ? ' warn' : ''}" title="${esc(w.line)}">${w.dot ? `<span class="sd ${w.dot}"></span>` : ''}<span>${esc(w.chip)}</span>${pr}${live}</p>`
package/src/ui-server.js CHANGED
@@ -22,8 +22,7 @@ import { autowork, autoConfig, publicConfig, AUTO_MODES } from './autowork.js'
22
22
  import { migrateDir } from './legacy.js'
23
23
  import { writePrivateJson } from './private-file.js'
24
24
  import { readAccount, saveAccount, workspaceGithub, setWorkspaceGithub, removeWorkspaceGithub, setOrgGithub, clearAccount, clearAccountIf, resumeAccount, startLink, waitForLink, fetchMe, signOut, revokeToken, accountFromProfile, renameSession, createAgentInvite, listAgents, listAccessTypes, listCollaborators, listGrants, putGrant, deleteGrant, inviteToSession, listSessionInvites, cancelSessionInvite, listWorkspaces, listOrgs, createWorkspace, getWorkspace, updateWorkspace, deleteWorkspace, putWorkspaceMember, removeWorkspaceMember, getAgentPlacement, putAgentPlacement, listOrgAgents, putWorkspaceAgent, deleteWorkspaceAgent, createWorkspaceAgentInvite, excludeSessionAgent, listExcludedSessionAgents, listSessionAgents, includeSessionAgent, setSessionWorkspace, moveSessionToWorkspace, listWorkspaceInvites, inviteToWorkspace, cancelWorkspaceInvite, listMyInvites, answerMyInvite, announceSessionStarted, announceWhenReported, listWorkspaceFiles, createWorkspaceFile, confirmWorkspaceFile, workspaceFileDownload, updateWorkspaceFile, deleteWorkspaceFile, createWorkspaceFolder, listWorkspaceFileVersions, githubStatus, githubConnectUrl, listSandboxKeys, createSandboxKey, rotateSandboxKey, revokeSandboxKey, removeSandboxKey, aiVmFollow, aiVmUnfollow, aiVmStatus, aiVmConnect, aiVmUpdate, aiVmSignIn, aiVmWake, aiVmDisconnect, apiUrl } from './account.js'
25
- import { personaName } from './persona.js'
26
- import { CLOUD_LABEL } from './cloud-ai.js'
25
+ import { aiName } from './persona.js'
27
26
  import { effectiveAccess, builtinType } from './session-access.js'
28
27
  import { cleanSessionName, BAD_SESSION_NAME, SESSION_NAME_MAX } from './session-name.js'
29
28
  import { personPasses, personApiAuth } from './pass-source.js'
@@ -35,6 +34,7 @@ import { queryHistory } from './history.js'
35
34
  import { contentDisposition } from './api/file-paths.js'
36
35
  import { getSecrets, putSecretSettings, setSecretWho, deleteSecret, secretUses, saveSecrets, secretAsks, answerAsk } from './secrets-client.js'
37
36
  import { envFilesIn, knownEnvFile, envValues } from './secret-import.js'
37
+ import { appWorkspaces } from './app-workspaces.js'
38
38
 
39
39
  const TOOL_NAMES = ['Claude Code', 'Cursor', 'Codex', 'xAI', 'Windsurf', 'GitHub Copilot', 'Zed', 'Aider', 'Other']
40
40
  const MAX_WS_FILE_BYTES = 500 * 1024 * 1024
@@ -558,7 +558,7 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
558
558
  // A session nobody named is shown as the app shows it: by this computer's folder for it.
559
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
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) } }
561
+ return { vm: { ...vm, sessions, name: aiName(person) } }
562
562
  }
563
563
  const cloudNone = (err) => { if (err.status === 404) return cloudView(null); throw err }
564
564
  /** What Settings may change: the tool, model, instructions and daily cap (blank is no cap). */
@@ -615,7 +615,8 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
615
615
  }
616
616
 
617
617
  // Workspaces live on the accounts API behind a flag: a 404 means off, and the app shows today's home.
618
- const workspaceList = () => asAccount(async (token) => {
618
+ // Off on this computer (app-workspaces.js) whatever the API says.
619
+ const workspaceList = () => !appWorkspaces() ? (workspacesOn = false, { on: false, workspaces: [] }) : asAccount(async (token) => {
619
620
  try {
620
621
  const workspaces = await listWorkspaces({ token })
621
622
  workspacesOn = true
@@ -799,7 +800,7 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
799
800
  try {
800
801
  const res = await fetch(`${apiUrl()}/v1/features`, { signal: AbortSignal.timeout(5000) })
801
802
  const f = res.ok ? await res.json() : {}
802
- return { secrets: !!f.secrets, workspaces: !!f.workspaces }
803
+ return { secrets: !!f.secrets, workspaces: !!f.workspaces && appWorkspaces() }
803
804
  } catch { return { secrets: false, workspaces: false } }
804
805
  },
805
806
  'POST /api/secrets/env-scan': (b) => {
@@ -893,7 +894,7 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
893
894
  }),
894
895
  'POST /api/workspaces/:id/invites/:iid/cancel': (b, id, url, iid) => asAccount(async (token) => { await cancelWorkspaceInvite({ token, id: needWorkspaceId(id), inviteId: needInviteId(iid) }); return { ok: true } }),
895
896
  // Invites waiting for you (workspaces, and sessions with their link); accept or decline them.
896
- 'GET /api/invites': () => asAccount(async (token) => ({ invites: await listMyInvites({ token }) })),
897
+ 'GET /api/invites': () => asAccount(async (token) => ({ invites: (await listMyInvites({ token })).filter((i) => i.kind !== 'workspace' || appWorkspaces()) })),
897
898
  'POST /api/invites/:id/accept': (b, id) => asAccount(async (token) => answerMyInvite({ token, id: needInviteId(id), how: 'accept' })),
898
899
  'POST /api/invites/:id/decline': (b, id) => asAccount(async (token) => answerMyInvite({ token, id: needInviteId(id), how: 'decline' })),
899
900
  'GET /api/workspaces/:id/files': (b, id, url) => asAccount(async (token) => ({ files: await listWorkspaceFiles({ token, id: needWorkspaceId(id), folder: url.searchParams.get('folder') ?? undefined }) })),
@@ -1069,13 +1070,15 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
1069
1070
  'GET /api/github/branches': (b, id, url) => gitops.listBranches(url.searchParams.get('repo')),
1070
1071
  'POST /api/sessions/:id/commit-request': (b, id) => { const r = get(id).requestCommit(b.message, { files: Array.isArray(b.files) ? b.files : null, task: b.task || null, by: b.by ? String(b.by).slice(0, 80) : null }); pushStatus(id); return r },
1071
1072
  // Commits a request in this folder (commit.js), and what is not committed here yet, by who made it.
1072
- 'POST /api/sessions/:id/commit-session': async (b, id) => { try { return await get(id).commitSessionWork(String(b.message || '')) } finally { pushStatus(id) } },
1073
+ 'POST /api/sessions/:id/commit-session': async (b, id) => { try { return await get(id).commitSessionWork(String(b.message || ''), { mine: b.mine === true }) } finally { pushStatus(id) } },
1073
1074
  'POST /api/sessions/:id/commit-request/commit': async (b, id) => { try { return await get(id).commitRequest(String(b.id || '')) } finally { pushStatus(id) } },
1074
1075
  'POST /api/sessions/:id/agent-commits': async (b, id) => { try { return await get(id).setAgentCommits(b.mode) } finally { pushStatus(id) } },
1075
1076
  'POST /api/sessions/:id/github-sync': async (b, id) => { try { return await get(id).setGithubSync(b.on !== false) } finally { pushStatus(id) } },
1076
1077
  'POST /api/sessions/:id/large-to-relay': async (b, id) => { try { return await get(id).setLargeToRelay(b.on === true) } finally { pushStatus(id) } },
1077
1078
  'POST /api/sessions/:id/github/recheck': async (b, id) => { try { return { github: await get(id).githubRecheck() } } finally { pushStatus(id) } },
1078
1079
  'GET /api/sessions/:id/uncommitted': (b, id) => get(id).uncommittedWork(),
1080
+ // The git panel: your own work not committed here (yours and your AIs'), each boxed or not.
1081
+ 'GET /api/sessions/:id/mine-uncommitted': (b, id) => get(id).myUncommitted(),
1079
1082
  'POST /api/sessions/:id/commit-request/done': (b, id) => { const done = get(id).resolveCommitRequests({ ids: b.id ? [String(b.id)] : null }); pushStatus(id); return { done } },
1080
1083
  'GET /api/fs': (b, id, url) => listDir(url.searchParams.get('path') || os.homedir()),
1081
1084
  // Reply first, then shut down, so the page hears back before we exit.