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/README.md +8 -10
- package/RELEASES.md +15 -0
- package/bin/quilt.js +1 -0
- package/package.json +1 -1
- package/src/ai-vm.js +52 -2
- package/src/api/server.js +4 -5
- package/src/app-workspaces.js +4 -0
- package/src/autowork.js +8 -3
- package/src/cloud-ai.js +10 -13
- package/src/hooks.js +3 -3
- package/src/inbox.js +2 -1
- package/src/mcp.js +9 -7
- package/src/persona.js +1 -1
- package/src/relay-mcp.js +0 -2
- package/src/session.js +87 -27
- package/src/setup.js +4 -4
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +16 -10
- package/src/ui/chat.js +8 -22
- package/src/ui/cloud-ai.js +4 -5
- package/src/ui/home.js +1 -87
- package/src/ui/loom-model.js +9 -10
- package/src/ui/rail-model.js +4 -3
- package/src/ui/session.js +66 -14
- package/src/ui-server.js +10 -7
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
|
-
//
|
|
5316
|
-
|
|
5317
|
-
return { name: this.name, aliases, asAi: this.kind
|
|
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
|
|
5353
|
-
return this.
|
|
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 =
|
|
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
|
|
5587
|
-
*
|
|
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
|
-
|
|
5592
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
5940
|
-
const ai = aiName(this.aiOf
|
|
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
|
|
5965
|
-
// A person's cloud AI is
|
|
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
|
|
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,
|
|
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"
|
|
5987
|
-
*
|
|
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,
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
package/src/ui/agent-guide.js
CHANGED
|
@@ -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
|
-
//
|
|
140
|
-
// ("Daniel · file-queue", "Daniel · Claude Code 3")
|
|
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":
|
|
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:
|
|
149
|
-
* into one "<person>'s AI"
|
|
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
|
-
|
|
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:
|
|
199
|
-
export const shownName = (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
|
package/src/ui/cloud-ai.js
CHANGED
|
@@ -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
|
|
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>
|
|
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
|
|
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
|
|
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', '
|
|
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
|
|
package/src/ui/loom-model.js
CHANGED
|
@@ -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
|
-
*
|
|
274
|
-
* Code", "Daniel ·
|
|
275
|
-
*
|
|
276
|
-
*
|
|
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) =>
|
|
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:
|
|
301
|
-
for (const person of
|
|
302
|
-
|
|
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)
|
package/src/ui/rail-model.js
CHANGED
|
@@ -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)
|
|
16
|
-
*
|
|
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
|
-
|
|
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).
|