heyquilt 0.3.107 → 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/src/session.js CHANGED
@@ -2892,7 +2892,7 @@ export class Session extends EventEmitter {
2892
2892
  if (changed.length && this.followsRelay && this.branchLive()) {
2893
2893
  const atHead = await filesAt(this.root, head.sha, changed)
2894
2894
  const has = () => atHead && changed.every((rel) => this.sharedKey(rel) === (atHead.get(rel) ?? undefined))
2895
- if (atHead && !atHead.failed && (has() || await this.relayHasPull(head.sha, has))) old = atHead
2895
+ if (atHead && !atHead.failed && (has() || await this.relayHasPull(head.sha, has))) old = this.pulledBases(changed, old, atHead)
2896
2896
  }
2897
2897
  if (old.failed) this.log(`⚠️ git could not read ${old.failed} file${old.failed === 1 ? '' : 's'} at the old commit; merged without a base, so where they differ the newer version stays`)
2898
2898
  // A file git made the person resolve (stash pop, rebase) holds their merge of the commits and the
@@ -2927,6 +2927,31 @@ export class Session extends EventEmitter {
2927
2927
  }
2928
2928
  }
2929
2929
 
2930
+ /**
2931
+ * The base of each pulled path once the relay is at the pulled commit (planSettle): the file at that commit,
2932
+ * so the pull's change isn't shared again on top of the relay's. Not for a file the session doesn't have at
2933
+ * that commit (the relay holds GitHub's change while someone holds the file) whose disk doesn't have the
2934
+ * pull's change either (git moved the branch and left the file, as `git reset` does): measured from the
2935
+ * commit, the disk would look like it took the pull's change away. That one is measured from the old commit.
2936
+ */
2937
+ pulledBases (rels, old, atHead) {
2938
+ const out = new Map(atHead)
2939
+ out.failed = 0
2940
+ const text = (k) => typeof k === 'string' && !k.startsWith('bin:')
2941
+ for (const rel of rels) {
2942
+ const pulled = atHead.get(rel) ?? undefined
2943
+ const was = old.get(rel) ?? undefined
2944
+ if (pulled === was || this.sharedKey(rel) === pulled) continue
2945
+ const disk = this.readDisk(rel)
2946
+ const ours = disk && !disk.skip && !disk.tooLarge ? disk.key : null
2947
+ if (ours === pulled) continue
2948
+ // The disk has the pull's change when combining the pull into it changes nothing.
2949
+ const hasPull = text(ours) && text(was) && text(pulled) && (() => { const m = merge3(was, ours, pulled); return !m.conflicts.length && m.text === ours })()
2950
+ if (!hasPull) out.set(rel, old.get(rel))
2951
+ }
2952
+ return out
2953
+ }
2954
+
2930
2955
  onDisk (rel) {
2931
2956
  try { fs.lstatSync(path.join(this.root, ...rel.split('/'))); return true } catch { return false }
2932
2957
  }
@@ -5312,9 +5337,9 @@ export class Session extends EventEmitter {
5312
5337
  const r = cloudReader({ name: this.name, person: this.aiOf, states: this.conn && this.conn.awareness ? [...this.conn.awareness.getStates().values()] : [] })
5313
5338
  return { ...r, own: [...r.own, ...own] }
5314
5339
  }
5315
- // With no AI session of ours live, what is written to "<me>'s AI" waits here for the next one.
5316
- const aliases = this.kind !== 'agent' && !this.leadPersona() ? [aiName(this.name)] : []
5317
- 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 } : {}) }
5318
5343
  }
5319
5344
 
5320
5345
  /**
@@ -5349,8 +5374,8 @@ export class Session extends EventEmitter {
5349
5374
  /** The names `via` (an AI session of ours, or this member when null) is known by in chat. */
5350
5375
  readerNames (via = null) {
5351
5376
  const p = this.persona(via)
5352
- if (p) return [p.name, ...p.aliases, aiName(this.name)]
5353
- 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)
5354
5379
  }
5355
5380
 
5356
5381
  /** Every message this member may read, the kept ones (chat-archive.js) before the room's, oldest first. */
@@ -5389,7 +5414,7 @@ export class Session extends EventEmitter {
5389
5414
  const r = (p ? p.inbox : this.inboxTracker).since(after)
5390
5415
  if (all) return { ...r, events: this.withContext(r.events, via), me: this.readerNames(via) }
5391
5416
  const msgs = this.chat.toArray().filter((m) => this.canSee(m))
5392
- const me = p ? [p.name, ...p.aliases, aiName(this.name)] : [this.name, aiName(this.name)]
5417
+ const me = this.readerNames(via)
5393
5418
  // A commit request (e.commit: its id) waits until the request is done; a note that a commit was made is shown once.
5394
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 ||
5395
5420
  (!this.settledIds.has(e.id) && !answered(msgs, me, e.by, e.ts))
@@ -5410,7 +5435,6 @@ export class Session extends EventEmitter {
5410
5435
  memberNames (me = this.name) {
5411
5436
  const names = new Set(this.peerNames())
5412
5437
  if (me !== this.name) names.add(this.name) // an AI session may write to its own person
5413
- for (const p of this.status().peers) if (p.persona && p.of && !p.mine) names.add(aiName(p.of)) // "Brandon's AI"
5414
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) }
5415
5439
  names.delete(me)
5416
5440
  return [...names].filter((n) => typeof n === 'string' && n)
@@ -5583,13 +5607,21 @@ export class Session extends EventEmitter {
5583
5607
 
5584
5608
  /**
5585
5609
  * Commits everything the session changed since its last commit, from this folder, with `message`,
5586
- * and pushes (the GitHub chip's Commit and push): the files here that differ from HEAD and sync.
5587
- * 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.
5588
5613
  */
5589
- async commitSessionWork (message) {
5614
+ async commitSessionWork (message, { mine = false } = {}) {
5590
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')
5591
- const dirty = await uncommitted(this.root)
5592
- 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
+ }
5593
5625
  if (!files.length) throw new Error('nothing to commit: this folder matches its last commit')
5594
5626
  const r = this.requestCommit(message, { files, by: this.name, quiet: true })
5595
5627
  return this.commitRequest(r.id)
@@ -5696,6 +5728,30 @@ export class Session extends EventEmitter {
5696
5728
  return { branch: this.git.branch, groups: uncommittedByPerson(editors), requests }
5697
5729
  }
5698
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
+
5699
5755
  /** Looks at the open requests again soon (one came in, or one was committed). */
5700
5756
  reviewCommitsSoon (ms = 5000) {
5701
5757
  if (this.stopped || !this.canCommit()) return
@@ -5934,10 +5990,13 @@ export class Session extends EventEmitter {
5934
5990
  return { changed: plan.changes.map((c) => c.path), manual: plan.manual }
5935
5991
  }
5936
5992
 
5937
- /** 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
+ */
5938
5997
  forMyAi (e) {
5939
- // A person's cloud AI works for its person's AI, and for itself by name.
5940
- const ai = aiName(this.aiOf || this.name)
5998
+ if (!this.aiOf) return false
5999
+ const ai = aiName(this.aiOf)
5941
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))
5942
6001
  const m = this.chat.toArray().find((x) => x && x.id === e.id)
5943
6002
  if (!m) return false
@@ -5961,16 +6020,16 @@ export class Session extends EventEmitter {
5961
6020
  /** Whether a name is this member's or one of its AI sessions' (now or before a rename). */
5962
6021
  isMine (name) {
5963
6022
  if (!name) return false
5964
- if (name === this.name || name === aiName(this.name)) return true
5965
- // 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.
5966
6025
  if (this.aiOf && name === aiName(this.aiOf)) return true
5967
6026
  for (const p of this.personas.values()) if (p.name === name || p.aliases.includes(name)) return true
5968
6027
  return false
5969
6028
  }
5970
6029
 
5971
- /** 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). */
5972
6031
  ownNames () {
5973
- 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])]
5974
6033
  }
5975
6034
 
5976
6035
  /** Whether a claim is this member's or one of its AI sessions': files on this disk are ours to write. */
@@ -5983,14 +6042,13 @@ export class Session extends EventEmitter {
5983
6042
  }
5984
6043
 
5985
6044
  /**
5986
- * How an AI session reads the inbox. What is written to "<person>'s AI" goes to one session
5987
- * 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.
5988
6047
  */
5989
6048
  personaReader (p) {
5990
- const aliases = p === this.leadPersona() ? [...p.aliases, aiName(this.name)] : p.aliases
5991
6049
  // Its sibling sessions here don't wake it: they work for the same person.
5992
6050
  const own = [...this.personas.values()].filter((q) => q !== p).flatMap((q) => [q.name, ...q.aliases])
5993
- 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 }
5994
6052
  }
5995
6053
 
5996
6054
  /** The AI session of ours that answers for "<person>'s AI": the live one heard from last, or null. */
@@ -6110,7 +6168,7 @@ export class Session extends EventEmitter {
6110
6168
  const reply = replyFor(visible, { names: this.ownNames(), to, re: re ? String(re).trim() : '' })
6111
6169
  if (reply.error) throw new Error(reply.error)
6112
6170
  to = reply.to
6113
- 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')
6114
6172
  const names = agent ? this.memberNames(by) : []
6115
6173
  const targets = agent ? addressees(text, to, names) : []
6116
6174
  if (agent && !file) {
@@ -6135,7 +6193,7 @@ export class Session extends EventEmitter {
6135
6193
  if (via) this.aiSent.push({ via: String(via), targets, text, ts: msg.ts })
6136
6194
  if (agent && text) this.noteActivity(`${to ? `To ${to}` : 'In chat'}: ${text}`, { via, kind: 'reply' })
6137
6195
  this.scheduleStatusWrite()
6138
- 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)
6139
6197
  const away = !online && (this.members || []).some((m) => m.away && m.name === to)
6140
6198
  return { ...this.describeMessage(msg), recipientOnline: online, ...(away ? { recipientAway: true } : {}) }
6141
6199
  }
@@ -6303,7 +6361,9 @@ export class Session extends EventEmitter {
6303
6361
 
6304
6362
  /** Whether a message is meant for me: a well-formed one that is public, mine, or addressed to me. */
6305
6363
  canSee (msg) {
6306
- 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))
6307
6367
  }
6308
6368
 
6309
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).