heyquilt 0.0.1 → 0.3.90
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/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
package/src/control.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Local control API (127.0.0.1 only) so the CLI and the MCP server can talk to
|
|
2
|
+
// a running session. Discovery info is written to .quilt/daemon.json.
|
|
3
|
+
import http from 'node:http'
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import crypto from 'node:crypto'
|
|
7
|
+
import { renderStatus } from './status.js'
|
|
8
|
+
import { migrateDir } from './legacy.js'
|
|
9
|
+
|
|
10
|
+
export async function startControl (session, extras = {}) {
|
|
11
|
+
const token = crypto.randomBytes(16).toString('hex')
|
|
12
|
+
const routes = {
|
|
13
|
+
'GET /status': (b) => {
|
|
14
|
+
// To one of the AI sessions working through this app, "you" is that session (persona.js).
|
|
15
|
+
const st = session.status()
|
|
16
|
+
const p = session.persona(b.via)
|
|
17
|
+
if (p) st.me = { ...st.me, name: p.name, tool: p.tool || st.me.tool, kind: 'agent', persona: true, of: session.name, focus: p.focus || '' }
|
|
18
|
+
return { ...st, markdown: renderStatus(st) }
|
|
19
|
+
},
|
|
20
|
+
// An AI session (one `quilt mcp`) says hello: { via, tool, cwd, ppid } -> { name, named }.
|
|
21
|
+
'POST /persona': (b) => session.registerPersona({ via: b.via, tool: b.tool, cwd: b.cwd, ppid: b.ppid, pids: b.mcpPids }),
|
|
22
|
+
// It names itself after its work: { via, name } -> { name }.
|
|
23
|
+
'POST /persona/name': (b) => session.renamePersona(b.via, b.name, 'self'),
|
|
24
|
+
// What the person asked their AI session (a prompt hook): names a session that has no name yet.
|
|
25
|
+
'POST /persona/prompt': (b) => { session.personaPrompt(b.via, b.text); return { ok: true } },
|
|
26
|
+
// The CLI and the MCP server: what an AI sends, held to the chat rules (duties.js). A person typing in `quilt join` sends with everyone.
|
|
27
|
+
'POST /say': (b) => session.say(b.text, { to: b.to, agent: true, via: b.via ? String(b.via) : null, everyone: !!b.everyone, also: !!b.also, re: typeof b.re === 'string' ? b.re.slice(0, 40) : '' }),
|
|
28
|
+
// "… is typing" in chat, for an AI session (via) or this member: { on, to } (sending clears it).
|
|
29
|
+
'POST /typing': (b) => session.setTyping(b.on !== false, { via: b.via ? String(b.via) : null, to: b.to || null, agent: true }),
|
|
30
|
+
'POST /inbox/settle': (b) => session.settle(b.ids),
|
|
31
|
+
'POST /send': (b) => session.sendFile(b.path, { to: b.to, text: b.text }),
|
|
32
|
+
'POST /conversation': (b) => session.conversation({ with: b.with || null, q: b.q || '', before: b.before || null, limit: b.limit, via: b.via ? String(b.via) : null }),
|
|
33
|
+
'POST /messages': (b) => ({ messages: session.messages({ limit: b.limit || 50, unreadOnly: !!b.unreadOnly, markRead: b.markRead !== false, withName: b.with || null }) }),
|
|
34
|
+
'POST /get': async (b) => ({ path: await session.fetchFile(b.id, b.dest) }),
|
|
35
|
+
'POST /focus': (b) => {
|
|
36
|
+
// An AI session's focus is its own (and names it, if it has no name yet); a person's is theirs.
|
|
37
|
+
if (session.persona(b.via)) session.personaSays(b.via, b.text)
|
|
38
|
+
else session.setFocus(b.text)
|
|
39
|
+
return { ok: true }
|
|
40
|
+
},
|
|
41
|
+
'POST /claim': (b) => session.claim(b.pattern, b.note, b.via),
|
|
42
|
+
// What this person's AI should hear (an edit of its that Quilt undone); handed over once.
|
|
43
|
+
'POST /notices': (b) => ({ notices: session.takeNotices(b && b.via ? String(b.via) : null) }),
|
|
44
|
+
'POST /release': async (b) => ({ released: await session.release(b.pattern, b.via) }),
|
|
45
|
+
'POST /unpack': (b) => session.unpack(b.paths, b.via),
|
|
46
|
+
// The file queue: ask for a file someone holds, hand one we hold to someone waiting, take a request back.
|
|
47
|
+
'POST /request-file': (b) => session.requestFile(b.path, { title: b.title, description: b.description, task: b.task, via: b.via }),
|
|
48
|
+
'POST /handoff': (b) => session.handoff(b.path, { to: b.to, context: b.context, via: b.via }),
|
|
49
|
+
'POST /withdraw-request': async (b) => ({ withdrawn: await session.withdrawRequest(b.request, b.via) }),
|
|
50
|
+
// Who owns one path (the hooks ask before every edit). `shared` is false for paths Quilt doesn't sync.
|
|
51
|
+
'POST /claim-for': (b) => {
|
|
52
|
+
const rel = String(b.path || '').replace(/\\/g, '/').replace(/^\.\//, '')
|
|
53
|
+
const shared = session.syncable(rel)
|
|
54
|
+
const c = shared ? session.claimFor(rel) : null
|
|
55
|
+
const me = session.actorName(b.via)
|
|
56
|
+
return { path: rel, shared, claim: c ? { by: c.by, pattern: c.pattern, note: c.note, queue: c.queue || [] } : null, mine: !!c && c.by === me, me, focus: session.focus || '' }
|
|
57
|
+
},
|
|
58
|
+
// Before an agent changes files (any tool): is each one ours to edit (free ones are claimed for
|
|
59
|
+
// us), and what did people ask about them? See Session.prepareEdit and duties.js.
|
|
60
|
+
'POST /before-edit': (b) => session.prepareEdit(Array.isArray(b.paths) ? b.paths.slice(0, 100) : [], b.via),
|
|
61
|
+
// An agent finished a piece of work: let go of the claims that followed its edits.
|
|
62
|
+
'POST /finish': (b) => session.finishEditing(b.via),
|
|
63
|
+
// Who is waiting for an answer from this member (or AI session): the MCP and the hooks refuse to move work on until there's none.
|
|
64
|
+
'GET /duties': (b) => session.duties(b.via),
|
|
65
|
+
// What an MCP agent shares about its work (quilt_share): the feed, tasks and "working", for any tool.
|
|
66
|
+
'POST /share-work': (b) => {
|
|
67
|
+
session.personaSays(b.via, b.request)
|
|
68
|
+
return session.shareAgentWork({ tool: b.tool, request: b.request, summary: b.summary, files: Array.isArray(b.files) ? b.files : [] })
|
|
69
|
+
},
|
|
70
|
+
// Owner only: a link a chat-only AI works through (chat-links.js). { name, hours } -> { url, name, expiresAt }
|
|
71
|
+
'POST /chat-link': (b) => session.createChatLink({ name: b.name, minutes: b.minutes }),
|
|
72
|
+
// Owner only: how long a chat link still works. { who: name or key, minutes } -> { name, expiresAt }
|
|
73
|
+
'POST /chat-link/extend': (b) => session.extendChatLink(String(b.who || ''), b.minutes),
|
|
74
|
+
// Owner only: the relay's read-only GitHub token, for commits while everyone's offline. { token } ('' clears) -> { githubToken }
|
|
75
|
+
'POST /github-token': (b) => session.setGithubToken(b.token),
|
|
76
|
+
'POST /agent': (b) => { session.addAgent(b.client); return { ok: true } },
|
|
77
|
+
'POST /feed': (b) => ({ entries: session.agentFeedFor(b.who, { limit: Math.min(Number(b.limit) || 40, 300) }) }),
|
|
78
|
+
// The chronology: { path, by, since, task, limit } (see Session.historyQuery).
|
|
79
|
+
'POST /history': (b) => ({ entries: session.historyQuery(b) }),
|
|
80
|
+
'GET /tree': () => session.tree(),
|
|
81
|
+
'POST /sharing': (b) => ({ on: session.setAgentSharing(b.on !== false) }),
|
|
82
|
+
'GET /commits': () => session.commitStatus({ includeMe: false }),
|
|
83
|
+
'GET /branches': () => ({ branches: session.status().branches, git: session.status().git }),
|
|
84
|
+
// The relay's own list, with where each branch stands with GitHub (quilt_sync_status).
|
|
85
|
+
'GET /sync-status': () => ({ branches: session.branchList || [], mine: session.branchKey ? session.branchKey() : '' }),
|
|
86
|
+
'POST /branches/sync': () => session.syncBranchNow(),
|
|
87
|
+
'POST /commit-request': (b) => session.requestCommit(b.message, { files: b.files == null ? null : b.files, description: b.description || '', task: b.task || null, via: b.via ? String(b.via) : null }),
|
|
88
|
+
// quilt_commit: the relay commits the session's copy of the files to GitHub (relay-commit.js).
|
|
89
|
+
'POST /agent-commits': (b) => session.setAgentCommits(b.mode),
|
|
90
|
+
// GitHub as a node: the owner's switch, and a person adopting an agent's branch (relay-outbound.js).
|
|
91
|
+
'POST /github-sync': async (b) => (await session.setGithubSync(b.on !== false)).githubSync === false ? { githubSync: false } : { githubSync: true },
|
|
92
|
+
'POST /adopt': (b) => session.adoptBranch(String(b.from || '')),
|
|
93
|
+
// The branch's pull requests on GitHub, and merging one, through the relay (relay-commit.js).
|
|
94
|
+
'POST /github/recheck': () => session.githubRecheck(),
|
|
95
|
+
'POST /unpublished': (b) => session.unpublished({ files: Array.isArray(b.files) ? b.files : null, task: b.task || null, column: b.column || '' }),
|
|
96
|
+
// git in an agent's folder: what it signs in to GitHub with (`quilt git-credential`), and whether a push may go (`quilt git-pre-push`).
|
|
97
|
+
'POST /git/credential': (b) => session.gitCredential({ host: b.host || '' }),
|
|
98
|
+
'POST /git/push-check': (b) => session.pushCheck({ remote: b.remote || 'origin', refs: Array.isArray(b.refs) ? b.refs : [] }),
|
|
99
|
+
|
|
100
|
+
// A commit request committed in this folder, which has git (commit.js).
|
|
101
|
+
'POST /commit-request/commit': (b) => session.commitRequest(String(b.id || '')),
|
|
102
|
+
'GET /uncommitted': () => session.uncommittedWork(),
|
|
103
|
+
'POST /commit-request/done': (b) => ({ done: session.resolveCommitRequests({ ids: b.id ? [String(b.id)] : null }) }),
|
|
104
|
+
'POST /work': (b) => {
|
|
105
|
+
if (b.state === 'working') session.personaSays(b.via, b.note)
|
|
106
|
+
const work = session.setWork(b.state, b.note)
|
|
107
|
+
session.stitchClock(b.via, b.state === 'working')
|
|
108
|
+
if (b.state === 'working' && b.note) session.noteActivity(`Working on: ${b.note}`, { via: b.via })
|
|
109
|
+
else if (b.state === 'done') session.noteActivity(b.note ? `Done: ${b.note}` : 'Done', { via: b.via })
|
|
110
|
+
return { work }
|
|
111
|
+
},
|
|
112
|
+
'GET /tasks': () => ({ tasks: session.taskList() }),
|
|
113
|
+
// Mentions, direct messages and tasks handed to this member since sequence number `after` (agents wake on these).
|
|
114
|
+
'POST /inbox': (b) => session.inbox({ after: b.after, all: !!b.all, via: b.via }),
|
|
115
|
+
// The agent's webhook: inbox events POSTed to a URL of its own (see webhooks.js).
|
|
116
|
+
'GET /webhook': () => ({ webhook: session.webhookInfo() }),
|
|
117
|
+
'POST /webhook': (b) => ({ webhook: session.setWebhook({ url: b.url, secret: b.secret, events: b.events, bearer: b.bearer }) }),
|
|
118
|
+
'POST /webhook/clear': () => ({ had: session.clearWebhook() }),
|
|
119
|
+
// Agents' task changes are written to their thread too (Session.noteTaskChange).
|
|
120
|
+
'POST /tasks': (b) => {
|
|
121
|
+
const task = session.addTask(b)
|
|
122
|
+
session.noteTaskChange(null, task, b.via)
|
|
123
|
+
return { task, tasks: session.taskList() }
|
|
124
|
+
},
|
|
125
|
+
'POST /tasks/update': async (b) => {
|
|
126
|
+
const before = session.taskList().find((t) => t.id === b.id) || null
|
|
127
|
+
// Done merges the task's pull request first (relay-commit.js): refused with the reason when it can't be.
|
|
128
|
+
const finished = b.column === 'done' && before && before.column !== 'done' ? await session.finishTask(b.id) : null
|
|
129
|
+
const task = session.updateTask(b)
|
|
130
|
+
session.noteTaskChange(before, task, b.via)
|
|
131
|
+
if (b.column === 'doing' && task) session.personaSays(b.via, task.title) // taking a task can name an AI session
|
|
132
|
+
if (task && (b.column === 'doing' || b.column === 'done')) session.stitchClock(b.via, b.column === 'doing')
|
|
133
|
+
// An agent taking a task gets a branch of its own for it (session.js branchForTask).
|
|
134
|
+
const branch = b.column === 'doing' && task && before && before.column !== 'doing' ? await session.branchForTask(task).catch(() => null) : null
|
|
135
|
+
return { task, tasks: session.taskList(), ...(finished && finished.merged ? { merged: finished.merged } : {}), ...(branch ? { branch } : {}) }
|
|
136
|
+
},
|
|
137
|
+
// What an agent gets when it picks a task up: history for its files, claims, the project's checks.
|
|
138
|
+
'POST /tasks/brief': (b) => session.taskBrief(b.id),
|
|
139
|
+
'POST /tasks/comment': (b) => {
|
|
140
|
+
const r = session.commentTask(b)
|
|
141
|
+
const t = session.taskList().find((x) => x.id === b.id)
|
|
142
|
+
if (t && b.text) session.noteActivity(`Note on "${String(t.title || '').slice(0, 120)}": ${String(b.text).slice(0, 400)}`, { via: b.via })
|
|
143
|
+
return r
|
|
144
|
+
},
|
|
145
|
+
'POST /tasks/delete': (b) => { session.deleteTask(b.id); return { tasks: session.taskList() } },
|
|
146
|
+
'GET /merges': () => {
|
|
147
|
+
const merges = session.mergeList()
|
|
148
|
+
// Agents are told the other version is under .quilt/merges/<id>/, so put
|
|
149
|
+
// it there on this machine too (base and ours are written once). A local
|
|
150
|
+
// record's versions are only on the opener's machine; skip any that fail.
|
|
151
|
+
for (const m of merges) {
|
|
152
|
+
if (m.state !== 'open' || m.local) continue
|
|
153
|
+
try { session.prepareMergeSend(m.id) } catch {}
|
|
154
|
+
}
|
|
155
|
+
return { merges }
|
|
156
|
+
},
|
|
157
|
+
'POST /merges/resolve': (b) => session.settleMerge(String(b.id || ''), { how: b.how }),
|
|
158
|
+
// quilt_clash: a file held because the session and GitHub changed the same lines (relay-clash.js).
|
|
159
|
+
'POST /clash': (b) => session.clash({ action: String(b.action || ''), path: String(b.path || ''), content: typeof b.content === 'string' ? b.content : null, checks: b.checks && typeof b.checks === 'object' ? b.checks : null, force: b.force === true }),
|
|
160
|
+
'POST /merges/send': (b) => session.sendMerge(String(b.id || '')),
|
|
161
|
+
'GET /rules': () => ({ text: session.projectRulesText() }),
|
|
162
|
+
'GET /info': (b) => ({ room: session.room, dir: session.root, name: session.actorName(b.via), person: session.name, kind: session.kind, invite: extras.invite || null, viewInvite: extras.viewInvite || null, access: session.access, pid: process.pid })
|
|
163
|
+
}
|
|
164
|
+
const server = http.createServer(async (req, res) => {
|
|
165
|
+
const reply = (code, body) => {
|
|
166
|
+
res.writeHead(code, { 'content-type': 'application/json' })
|
|
167
|
+
res.end(JSON.stringify(body))
|
|
168
|
+
}
|
|
169
|
+
if (req.headers.authorization !== `Bearer ${token}`) return reply(401, { error: 'unauthorized' })
|
|
170
|
+
if (req.method === 'GET' && req.url === '/events') return streamEvents(session, req, res)
|
|
171
|
+
const route = routes[`${req.method} ${req.url.split('?')[0]}`]
|
|
172
|
+
if (!route) return reply(404, { error: 'not found' })
|
|
173
|
+
let raw = ''
|
|
174
|
+
for await (const chunk of req) raw += chunk
|
|
175
|
+
try {
|
|
176
|
+
const b = raw ? JSON.parse(raw) : {}
|
|
177
|
+
// Which AI session is asking (persona.js): `via` from its MCP server, or `pids` from a hook
|
|
178
|
+
// (the session whose tool process is among the hook's parents). GETs carry it in the query.
|
|
179
|
+
const q = new URL(req.url, 'http://x').searchParams
|
|
180
|
+
if (!b.via && q.get('via')) b.via = q.get('via')
|
|
181
|
+
if (!b.pids && q.get('pids')) b.pids = q.get('pids').split(',')
|
|
182
|
+
if (!b.via && b.pids) b.via = session.personaFor(b.pids)
|
|
183
|
+
// A background poll (the inbox push loop) is not the AI at work: only real calls keep it here.
|
|
184
|
+
if (b.via && !b.poll) session.touchPersona(b.via)
|
|
185
|
+
reply(200, await route(b))
|
|
186
|
+
} catch (err) {
|
|
187
|
+
reply(400, { error: err.message })
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
191
|
+
const file = path.join(session.stateDir, 'daemon.json')
|
|
192
|
+
fs.writeFileSync(file, JSON.stringify({ port: server.address().port, token, pid: process.pid }), { mode: 0o600 })
|
|
193
|
+
return {
|
|
194
|
+
close: () => {
|
|
195
|
+
try { fs.rmSync(file) } catch {}
|
|
196
|
+
return new Promise((r) => server.close(r))
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Server-sent events: pushes each new message as it arrives (used by `quilt chat`).
|
|
202
|
+
function streamEvents (session, req, res) {
|
|
203
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
|
|
204
|
+
res.write(': connected\n\n')
|
|
205
|
+
const onMessage = (msg) => {
|
|
206
|
+
res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`)
|
|
207
|
+
if (msg.by !== session.name) session.markRead([msg.id])
|
|
208
|
+
}
|
|
209
|
+
const onLog = (line) => res.write(`event: log\ndata: ${JSON.stringify(line)}\n\n`)
|
|
210
|
+
const ping = setInterval(() => res.write(': ping\n\n'), 15000)
|
|
211
|
+
session.on('message', onMessage)
|
|
212
|
+
session.on('log', onLog)
|
|
213
|
+
req.on('close', () => {
|
|
214
|
+
clearInterval(ping)
|
|
215
|
+
session.off('message', onMessage)
|
|
216
|
+
session.off('log', onLog)
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Finds the nearest folder (from `start` upward) with a running session. */
|
|
221
|
+
export function findDaemon (start = process.env.QUILT_DIR || process.cwd()) {
|
|
222
|
+
let dir = path.resolve(start)
|
|
223
|
+
while (true) {
|
|
224
|
+
const file = path.join(migrateDir(dir), 'daemon.json')
|
|
225
|
+
if (fs.existsSync(file)) {
|
|
226
|
+
const info = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
227
|
+
try { process.kill(info.pid, 0) } catch { return null } // stale
|
|
228
|
+
return { ...info, dir }
|
|
229
|
+
}
|
|
230
|
+
const parent = path.dirname(dir)
|
|
231
|
+
if (parent === dir) return null
|
|
232
|
+
dir = parent
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function call (daemon, method, route, body) {
|
|
237
|
+
const res = await fetch(`http://127.0.0.1:${daemon.port}${route}`, {
|
|
238
|
+
method,
|
|
239
|
+
headers: { authorization: `Bearer ${daemon.token}`, 'content-type': 'application/json' },
|
|
240
|
+
body: body ? JSON.stringify(body) : undefined
|
|
241
|
+
})
|
|
242
|
+
const json = await res.json()
|
|
243
|
+
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`)
|
|
244
|
+
return json
|
|
245
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// The conversation between two members: what one said to the other (direct messages either
|
|
2
|
+
// way, messages in the room that @mention the other, and what either said in the room to
|
|
3
|
+
// nobody in particular, which is how people talk on). An agent woken by one message
|
|
4
|
+
// (a webhook, a channel event, quilt_inbox) is often a fresh session that knows nothing of
|
|
5
|
+
// what came before: "is that tool live?" means nothing without the ten messages above it.
|
|
6
|
+
// So every wake carries the recent conversation with whoever woke it (contextFor), and
|
|
7
|
+
// quilt_conversation reads further back, searches it, or lists everything the reader can see.
|
|
8
|
+
//
|
|
9
|
+
// Pure: messages in, messages out. The local daemon, the hosted relay MCP and the webhooks
|
|
10
|
+
// all use it, so every agent, whatever tool it runs in, gets the same context.
|
|
11
|
+
import { mentioned, mentionsAnyone, ALL_AGENTS, messageLine, renderContext, CONTEXT_MESSAGE_CHARS } from './inbox.js'
|
|
12
|
+
|
|
13
|
+
export { messageLine, renderContext }
|
|
14
|
+
|
|
15
|
+
// What a wake carries: the last few messages, each cut short, within a budget for the whole.
|
|
16
|
+
export const CONTEXT_MESSAGES = 10
|
|
17
|
+
export const CONTEXT_CHARS = 4000
|
|
18
|
+
export { CONTEXT_MESSAGE_CHARS }
|
|
19
|
+
export const MAX_QUERY = 200
|
|
20
|
+
|
|
21
|
+
const lower = (names) => new Set((names || []).filter(Boolean).map((n) => String(n).toLowerCase()))
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether `m` passes between `me` (the reader's names) and `other` (the other member's names):
|
|
25
|
+
* a direct message from one to the other, or a room message by one that @mentions the other
|
|
26
|
+
* (@Agents counts for an agent reader) or names nobody at all (a message to someone else is not
|
|
27
|
+
* part of it). `of` (the person an AI session works for) counts as the sender too, so
|
|
28
|
+
* "Daniel · fix bug" writing for Daniel is Daniel's side of the conversation.
|
|
29
|
+
*/
|
|
30
|
+
export function between (m, me, other, { agent = false } = {}) {
|
|
31
|
+
if (!m || typeof m.text !== 'string') return false
|
|
32
|
+
const mine = lower(me)
|
|
33
|
+
const theirs = lower(other)
|
|
34
|
+
const by = String(m.by || '').toLowerCase()
|
|
35
|
+
const of = String(m.of || '').toLowerCase()
|
|
36
|
+
const to = m.to ? String(m.to).toLowerCase() : ''
|
|
37
|
+
const fromThem = theirs.has(by) || (of && theirs.has(of))
|
|
38
|
+
const fromMe = mine.has(by)
|
|
39
|
+
if (fromThem && !fromMe) return to ? mine.has(to) : !mentionsAnyone(m.text) || mentioned(m.text, [...me, ...(agent ? [ALL_AGENTS] : [])]).length > 0
|
|
40
|
+
if (fromMe && !fromThem) return to ? theirs.has(to) : !mentionsAnyone(m.text) || mentioned(m.text, other).length > 0
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const cut = (text, max) => {
|
|
45
|
+
const t = String(text || '').trim()
|
|
46
|
+
return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const slim = (m, max) => ({ id: m.id, by: m.by, ...(m.to ? { to: m.to } : {}), text: cut(m.text, max), ts: m.ts })
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The conversation between `me` and `other` before the message `before` (an id or a timestamp):
|
|
53
|
+
* at most `max` messages and `chars` characters, oldest first. `earlier` counts what is left out.
|
|
54
|
+
* `pin`: the id of a message always shown (the one being answered), even when older than the rest.
|
|
55
|
+
*/
|
|
56
|
+
export function contextFor (messages, { me, other, before = null, max = CONTEXT_MESSAGES, chars = CONTEXT_CHARS, each = CONTEXT_MESSAGE_CHARS, agent = false, pin = '' } = {}) {
|
|
57
|
+
const list = messages || []
|
|
58
|
+
let end = list.length
|
|
59
|
+
const i = typeof before === 'string' ? list.findIndex((m) => m && m.id === before)
|
|
60
|
+
: typeof before === 'number' ? list.findIndex((m) => m && (m.ts || 0) >= before) : -1
|
|
61
|
+
if (i >= 0) end = i
|
|
62
|
+
const all = []
|
|
63
|
+
for (let i = 0; i < end; i++) if (between(list[i], me, other, { agent })) all.push(list[i])
|
|
64
|
+
const out = []
|
|
65
|
+
let used = 0
|
|
66
|
+
for (let i = all.length - 1; i >= 0 && out.length < max; i--) {
|
|
67
|
+
const s = slim(all[i], each)
|
|
68
|
+
if (out.length && used + s.text.length > chars) break
|
|
69
|
+
used += s.text.length
|
|
70
|
+
out.unshift(s)
|
|
71
|
+
}
|
|
72
|
+
const shown = out.length
|
|
73
|
+
let pinned = false
|
|
74
|
+
if (pin && !out.some((m) => m.id === pin)) {
|
|
75
|
+
const p = list.find((m) => m && m.id === pin)
|
|
76
|
+
if (p) { out.unshift(slim(p, each)); pinned = all.includes(p) }
|
|
77
|
+
}
|
|
78
|
+
return { messages: out, earlier: all.length - shown - (pinned ? 1 : 0) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Adds `context` (contextFor) to each message or task event: the conversation with whoever it is
|
|
83
|
+
* from, before it. `reader`: { names, agent }. Events are inbox events ({ id, kind, by, ts }).
|
|
84
|
+
*/
|
|
85
|
+
export function withContext (events, messages, reader, opts = {}) {
|
|
86
|
+
return (events || []).map((e) => {
|
|
87
|
+
if (!e || !e.by || e.context) return e
|
|
88
|
+
const before = e.kind === 'dm' || e.kind === 'mention' ? e.id : (e.ts || null)
|
|
89
|
+
const c = contextFor(messages, { me: reader.names, other: [e.by], before, agent: !!reader.agent, pin: e.re || '', ...opts })
|
|
90
|
+
return c.messages.length ? { ...e, context: c.messages, earlier: c.earlier } : e
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Reads the chat a member can see: everything, or only the conversation `with` someone; only
|
|
96
|
+
* what contains `q` (any case, in the text or the sender's name); only before message `before`.
|
|
97
|
+
* Newest `limit`, oldest first. { messages, more }: more says older matches exist (ask with
|
|
98
|
+
* before: the first id).
|
|
99
|
+
*/
|
|
100
|
+
export function queryConversation (messages, { me = [], with: other = null, q = '', before = null, limit = 30, agent = false } = {}) {
|
|
101
|
+
let list = (messages || []).filter((m) => m && typeof m.id === 'string')
|
|
102
|
+
if (before) {
|
|
103
|
+
const i = list.findIndex((m) => m.id === before)
|
|
104
|
+
if (i >= 0) list = list.slice(0, i)
|
|
105
|
+
}
|
|
106
|
+
if (other) list = list.filter((m) => between(m, me, [other], { agent }))
|
|
107
|
+
const needle = String(q || '').trim().toLowerCase()
|
|
108
|
+
if (needle) list = list.filter((m) => String(m.text || '').toLowerCase().includes(needle) || String(m.by || '').toLowerCase().includes(needle))
|
|
109
|
+
const n = Math.max(1, Math.min(Number(limit) || 30, MAX_QUERY))
|
|
110
|
+
return { messages: list.slice(-n), more: list.length > n }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** quilt_conversation's answer. */
|
|
114
|
+
export function renderConversation ({ messages, more }, { me = [], with: other = null, q = '', now = Date.now() } = {}) {
|
|
115
|
+
const what = `${other ? `between you and ${other}` : 'you can see'}${q ? ` containing "${q}"` : ''}`
|
|
116
|
+
if (!messages.length) return `No messages ${what}${other ? ' (Quilt keeps the session\'s recent chat; older messages may be gone)' : ''}.`
|
|
117
|
+
const lines = messages.map((m) => messageLine(m, { me, now }))
|
|
118
|
+
const older = more ? `\nOlder messages ${what}: call again with before: "${messages[0].id}".` : ''
|
|
119
|
+
return `Messages ${what}, oldest first:\n${lines.join('\n')}${older}`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** What quilt_conversation does, the same for the local and the hosted tool. */
|
|
123
|
+
export const CONVERSATION_DESCRIPTION = 'Read back through the chat, further than quilt_read_messages: the whole conversation between you and one person or agent ' +
|
|
124
|
+
'(direct messages either way, messages that @mention one of you, and what either of you said in the room to nobody in particular), or everything you can see; search it for some text; page back with before. ' +
|
|
125
|
+
'Read it whenever a message refers to something earlier ("that tool", "the fix", "is it live?") that you do not have in front of you: ask, do not guess.'
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// `quilt doctor`: explains what quilt can see of this machine's AI chats, so a
|
|
2
|
+
// missing feed can be diagnosed from one pasted report. It prints counts,
|
|
3
|
+
// lengths and names only, never chat text, so the report is safe to share.
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { cursorUserDir, findWorkspaces } from './agents/cursor.js'
|
|
8
|
+
import { claudeProjectsDir, slugFor } from './agents/claude-code.js'
|
|
9
|
+
import { startAgentReaders } from './agents/index.js'
|
|
10
|
+
|
|
11
|
+
export async function doctor ({ dir = process.cwd(), watchSeconds = 0, print = console.log, cursorDir } = {}) {
|
|
12
|
+
dir = path.resolve(dir)
|
|
13
|
+
const say = (s = '') => print(s)
|
|
14
|
+
say(`quilt doctor for ${dir}`)
|
|
15
|
+
say(`node ${process.version} on ${process.platform}${cloudHint()}`)
|
|
16
|
+
say()
|
|
17
|
+
claude(dir, say)
|
|
18
|
+
say()
|
|
19
|
+
await cursor(dir, say, cursorDir)
|
|
20
|
+
if (watchSeconds > 0) {
|
|
21
|
+
say()
|
|
22
|
+
await watch(dir, watchSeconds, say)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cloudHint () {
|
|
27
|
+
const env = process.env
|
|
28
|
+
if (env.CLAUDE_CODE_REMOTE) return ' · looks like a Claude Code cloud session'
|
|
29
|
+
if (env.CURSOR_AGENT || env.CURSOR_BACKGROUND_AGENT) return ' · looks like a Cursor cloud agent'
|
|
30
|
+
if (env.CODESPACES) return ' · GitHub Codespaces'
|
|
31
|
+
return ''
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function claude (dir, say) {
|
|
35
|
+
say('## Claude Code')
|
|
36
|
+
const root = claudeProjectsDir()
|
|
37
|
+
if (!fs.existsSync(root)) return say(`- no ${root} (Claude Code hasn't run as this user on this machine)`)
|
|
38
|
+
const slug = slugFor(dir)
|
|
39
|
+
const dirs = fs.readdirSync(root).filter((n) => n.startsWith(slug))
|
|
40
|
+
if (!dirs.length) return say(`- no transcripts for this folder (looked for ${path.join(root, slug)}*)`)
|
|
41
|
+
for (const d of dirs) {
|
|
42
|
+
const files = fs.readdirSync(path.join(root, d)).filter((n) => n.endsWith('.jsonl'))
|
|
43
|
+
const newest = files.map((n) => fs.statSync(path.join(root, d, n)).mtimeMs).sort((a, b) => b - a)[0]
|
|
44
|
+
say(`- ${d}: ${files.length} conversation(s)${newest ? `, last written ${ago(newest)}` : ''}`)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function cursor (dir, say, userDir = cursorUserDir()) {
|
|
49
|
+
say('## Cursor')
|
|
50
|
+
let DatabaseSync
|
|
51
|
+
const emit = process.emitWarning
|
|
52
|
+
process.emitWarning = () => {}
|
|
53
|
+
try { ({ DatabaseSync } = await import('node:sqlite')) } catch {
|
|
54
|
+
return say('- ✗ node:sqlite is missing: reading Cursor chats needs Node.js 22.13 or newer. This alone explains a missing Cursor feed.')
|
|
55
|
+
} finally { process.emitWarning = emit }
|
|
56
|
+
|
|
57
|
+
if (!fs.existsSync(userDir)) return say(`- no Cursor data at ${userDir} (Cursor isn't installed for this user, or runs elsewhere)`)
|
|
58
|
+
say(`- Cursor data: ${userDir}`)
|
|
59
|
+
const global = path.join(userDir, 'globalStorage', 'state.vscdb')
|
|
60
|
+
if (!fs.existsSync(global)) return say(`- ✗ missing ${global}`)
|
|
61
|
+
const found = findWorkspaces(userDir, dir)
|
|
62
|
+
if (!found.length) {
|
|
63
|
+
say('- ✗ Cursor has never opened this exact folder. Folders Cursor has opened recently:')
|
|
64
|
+
for (const f of recentWorkspaceFolders(userDir).slice(0, 12)) say(` ${f}`)
|
|
65
|
+
say(' If the project is in that list under another path (a parent folder, a symlink, a different drive letter or case), open this exact folder in Cursor, or run quilt there.')
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
say(`- workspace folder(s) for this project: ${found.map((f) => path.basename(f)).join(', ')}`)
|
|
69
|
+
|
|
70
|
+
const open = (f) => new DatabaseSync(f, { readOnly: true })
|
|
71
|
+
const get = (db, table, key) => {
|
|
72
|
+
try {
|
|
73
|
+
const row = db.prepare(`SELECT value FROM ${table} WHERE key = ?`).get(key)
|
|
74
|
+
if (!row || row.value == null) return null
|
|
75
|
+
return JSON.parse(typeof row.value === 'string' ? row.value : Buffer.from(row.value).toString('utf8'))
|
|
76
|
+
} catch (err) { return { __error: err.message } }
|
|
77
|
+
}
|
|
78
|
+
const ids = new Map() // composerId -> lastUpdatedAt
|
|
79
|
+
const note = (c, from) => { if (c && c.composerId) ids.set(c.composerId, Math.max(ids.get(c.composerId) || 0, c.lastUpdatedAt || c.createdAt || 0)) ; return from }
|
|
80
|
+
for (const ws of found) {
|
|
81
|
+
const db = open(path.join(ws, 'state.vscdb'))
|
|
82
|
+
const list = get(db, 'ItemTable', 'composer.composerData')
|
|
83
|
+
db.close()
|
|
84
|
+
if (list && list.__error) say(`- ✗ ${path.basename(ws)}: can't read composer list (${list.__error})`)
|
|
85
|
+
else say(`- ${path.basename(ws)}: ${list && Array.isArray(list.allComposers) ? list.allComposers.length : 0} conversation(s) in the workspace list${list && list.hasMigratedComposerData ? ' (marked as migrated)' : ''}`)
|
|
86
|
+
for (const c of (list && list.allComposers) || []) note(c)
|
|
87
|
+
}
|
|
88
|
+
const g = open(global)
|
|
89
|
+
const headers = get(g, 'ItemTable', 'composer.composerHeaders')
|
|
90
|
+
if (headers && Array.isArray(headers.allComposers)) {
|
|
91
|
+
const wsIds = new Set(found.map((f) => path.basename(f)))
|
|
92
|
+
const mine = headers.allComposers.filter((c) => c && c.workspaceIdentifier && wsIds.has(c.workspaceIdentifier.id))
|
|
93
|
+
say(`- global conversation list: ${headers.allComposers.length} total, ${mine.length} tagged with this workspace`)
|
|
94
|
+
for (const c of mine) note(c)
|
|
95
|
+
} else say('- global conversation list: not present (older Cursor)')
|
|
96
|
+
|
|
97
|
+
const recent = [...ids].sort((a, b) => b[1] - a[1]).slice(0, 3)
|
|
98
|
+
if (!recent.length) say('- ✗ no conversations found for this folder yet. Send one message in Cursor\'s chat, then run this again.')
|
|
99
|
+
for (const [id, ts] of recent) {
|
|
100
|
+
const data = get(g, 'cursorDiskKV', `composerData:${id}`)
|
|
101
|
+
if (!data) { say(`- conversation ${id.slice(0, 8)} (${ago(ts)}): ✗ no composerData row`); continue }
|
|
102
|
+
if (data.__error) { say(`- conversation ${id.slice(0, 8)}: ✗ unreadable (${data.__error})`); continue }
|
|
103
|
+
const hs = Array.isArray(data.fullConversationHeadersOnly) ? data.fullConversationHeadersOnly : Array.isArray(data.conversation) ? data.conversation : null
|
|
104
|
+
if (!hs) { say(`- conversation ${id.slice(0, 8)}: ✗ unknown layout (_v ${data._v}, keys: ${Object.keys(data).slice(0, 12).join(', ')})`); continue }
|
|
105
|
+
say(`- conversation ${id.slice(0, 8)} (${ago(ts)}, layout _v ${data._v ?? '?'}): ${hs.length} message(s)`)
|
|
106
|
+
for (const h of hs.slice(-4)) {
|
|
107
|
+
const b = h.text !== undefined ? h : get(g, 'cursorDiskKV', `bubbleId:${id}:${h.bubbleId}`)
|
|
108
|
+
if (!b) { say(` · ${h.bubbleId?.slice(0, 8)}: ✗ bubble row missing`); continue }
|
|
109
|
+
const kind = b.type === 1 ? 'you' : b.type === 2 ? 'AI' : `type ${b.type}`
|
|
110
|
+
const extra = [b.toolFormerData?.name && `tool ${b.toolFormerData.name}`, b.thinking && 'thinking', !b.text && b.richText && 'richText only'].filter(Boolean).join(', ')
|
|
111
|
+
say(` · ${kind}: ${(b.text || '').length} chars${extra ? ` (${extra})` : ''}`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
g.close()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function recentWorkspaceFolders (userDir) {
|
|
118
|
+
const root = path.join(userDir, 'workspaceStorage')
|
|
119
|
+
const out = []
|
|
120
|
+
let names = []
|
|
121
|
+
try { names = fs.readdirSync(root) } catch { return out }
|
|
122
|
+
for (const n of names) {
|
|
123
|
+
try {
|
|
124
|
+
const ws = JSON.parse(fs.readFileSync(path.join(root, n, 'workspace.json'), 'utf8'))
|
|
125
|
+
const f = ws.folder || ws.workspace || ''
|
|
126
|
+
let shown = f
|
|
127
|
+
try { if (String(f).startsWith('file:')) shown = fileURLToPath(f) } catch {}
|
|
128
|
+
out.push({ shown, t: fs.statSync(path.join(root, n)).mtimeMs })
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
131
|
+
return out.sort((a, b) => b.t - a.t).map((x) => x.shown)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function watch (dir, seconds, say) {
|
|
135
|
+
say(`## Live check (${seconds}s): send a message in Cursor or Claude Code now`)
|
|
136
|
+
let n = 0
|
|
137
|
+
const r = startAgentReaders({
|
|
138
|
+
dir,
|
|
139
|
+
onEntries: (es) => { for (const e of es) { n++; say(`- ${e.tool}: ${e.kind}, ${e.text.length} chars`) } },
|
|
140
|
+
onState: (s) => say(`- state: ${s.tool || 'no tool yet'} ${s.status}${s.reason ? ` (${s.reason})` : ''}${s.notes ? ` · ${s.notes.join('; ')}` : ''}`),
|
|
141
|
+
onLog: (l) => say(`- log: ${l}`)
|
|
142
|
+
})
|
|
143
|
+
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
|
|
144
|
+
r.stop()
|
|
145
|
+
say(n ? `- ✓ ${n} entr${n === 1 ? 'y' : 'ies'} would have been shared` : '- ✗ nothing would have been shared in that time')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function ago (ts) {
|
|
149
|
+
if (!ts) return 'unknown time'
|
|
150
|
+
const s = Math.round((Date.now() - ts) / 1000)
|
|
151
|
+
if (s < 90) return `${s}s ago`
|
|
152
|
+
if (s < 5400) return `${Math.round(s / 60)}m ago`
|
|
153
|
+
if (s < 172800) return `${Math.round(s / 3600)}h ago`
|
|
154
|
+
return `${Math.round(s / 86400)}d ago`
|
|
155
|
+
}
|
|
156
|
+
|