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 +8 -10
- package/RELEASES.md +11 -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 +61 -26
- 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/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
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
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 {
|
|
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
|
|
150
|
-
* AI
|
|
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:
|
|
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
|
-
/**
|
|
66
|
-
|
|
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
|
|
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)
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
35
|
-
* "<person>'s AI"
|
|
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:
|
|
38
|
+
aliases: [...new Set([...aliases, aiName(person)])].filter((n) => n !== name),
|
|
42
39
|
agent: true,
|
|
43
40
|
asAi: false,
|
|
44
|
-
|
|
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
|
-
//
|
|
161
|
-
const who = st.peers.length ? foldPersonas(st.peers).map((p) => p.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1205
|
-
* folder's agent, or the person signed in on
|
|
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":
|
|
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
|
-
//
|
|
5341
|
-
|
|
5342
|
-
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 } : {}) }
|
|
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
|
|
5378
|
-
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)
|
|
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 =
|
|
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
|
|
5612
|
-
*
|
|
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
|
-
|
|
5617
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
5965
|
-
const ai = aiName(this.aiOf
|
|
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
|
|
5990
|
-
// 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.
|
|
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
|
|
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,
|
|
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"
|
|
6012
|
-
*
|
|
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,
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
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).
|
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
|
|
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
|
-
|
|
882
|
-
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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.
|