accrete-kit 0.1.0 → 0.2.0

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +9 -2
  3. package/dist/scaffold/assets/generated/release.json +1 -1
  4. package/dist/scaffold/manifest.json +2 -2
  5. package/dist/ui/LICENSE +21 -0
  6. package/dist/ui/THIRD-PARTY-NOTICES +212 -0
  7. package/dist/ui/bin/accrete-ui.mjs +46 -0
  8. package/dist/ui/page/app.js +49 -0
  9. package/dist/ui/page/index.html +17 -0
  10. package/dist/ui/page/panels/activity.css +80 -0
  11. package/dist/ui/page/panels/activity.js +85 -0
  12. package/dist/ui/page/panels/agents.css +61 -0
  13. package/dist/ui/page/panels/agents.js +173 -0
  14. package/dist/ui/page/panels/objects.css +155 -0
  15. package/dist/ui/page/panels/objects.js +309 -0
  16. package/dist/ui/page/panels/running.css +35 -0
  17. package/dist/ui/page/panels/running.js +159 -0
  18. package/dist/ui/page/style.css +86 -0
  19. package/dist/ui/page/terminal.js +88 -0
  20. package/dist/ui/server/agent-sessions.mjs +600 -0
  21. package/dist/ui/server/auth.mjs +29 -0
  22. package/dist/ui/server/deps.mjs +12 -0
  23. package/dist/ui/server/documents.mjs +285 -0
  24. package/dist/ui/server/events.mjs +18 -0
  25. package/dist/ui/server/processes.mjs +110 -0
  26. package/dist/ui/server/readers/activity.mjs +134 -0
  27. package/dist/ui/server/readers/agents.mjs +45 -0
  28. package/dist/ui/server/readers/objects.mjs +261 -0
  29. package/dist/ui/server/readers/running.mjs +245 -0
  30. package/dist/ui/server/scope.mjs +36 -0
  31. package/dist/ui/server/server.mjs +156 -0
  32. package/dist/ui/server/terminal.mjs +134 -0
  33. package/dist/ui/server/workspace.mjs +26 -0
  34. package/dist/ui/ui.json +3 -0
  35. package/dist/ui/vendor/@xterm/addon-fit/lib/addon-fit.mjs +18 -0
  36. package/dist/ui/vendor/@xterm/addon-unicode11/lib/addon-unicode11.mjs +44 -0
  37. package/dist/ui/vendor/@xterm/addon-webgl/lib/addon-webgl.mjs +101 -0
  38. package/dist/ui/vendor/@xterm/xterm/css/xterm.css +285 -0
  39. package/dist/ui/vendor/@xterm/xterm/lib/xterm.mjs +53 -0
  40. package/dist/ui/vendor/node-pty/lib/eventEmitter2.js +47 -0
  41. package/dist/ui/vendor/node-pty/lib/index.js +52 -0
  42. package/dist/ui/vendor/node-pty/lib/interfaces.js +7 -0
  43. package/dist/ui/vendor/node-pty/lib/terminal.js +190 -0
  44. package/dist/ui/vendor/node-pty/lib/types.js +7 -0
  45. package/dist/ui/vendor/node-pty/lib/unixTerminal.js +346 -0
  46. package/dist/ui/vendor/node-pty/lib/utils.js +39 -0
  47. package/dist/ui/vendor/node-pty/package.json +64 -0
  48. package/dist/ui/vendor/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  49. package/dist/ui/vendor/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  50. package/dist/ui/vendor/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  51. package/dist/ui/vendor/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  52. package/dist/ui/vendor/ws/index.js +22 -0
  53. package/dist/ui/vendor/ws/lib/buffer-util.js +142 -0
  54. package/dist/ui/vendor/ws/lib/constants.js +19 -0
  55. package/dist/ui/vendor/ws/lib/event-target.js +292 -0
  56. package/dist/ui/vendor/ws/lib/extension.js +203 -0
  57. package/dist/ui/vendor/ws/lib/limiter.js +55 -0
  58. package/dist/ui/vendor/ws/lib/permessage-deflate.js +530 -0
  59. package/dist/ui/vendor/ws/lib/receiver.js +743 -0
  60. package/dist/ui/vendor/ws/lib/sender.js +610 -0
  61. package/dist/ui/vendor/ws/lib/stream.js +161 -0
  62. package/dist/ui/vendor/ws/lib/subprotocol.js +62 -0
  63. package/dist/ui/vendor/ws/lib/validation.js +152 -0
  64. package/dist/ui/vendor/ws/lib/websocket-server.js +562 -0
  65. package/dist/ui/vendor/ws/lib/websocket.js +1426 -0
  66. package/dist/ui/vendor/ws/package.json +74 -0
  67. package/lib/cli.mjs +22 -4
  68. package/lib/ui.mjs +24 -0
  69. package/package.json +5 -5
@@ -0,0 +1,285 @@
1
+ // One scan of a Workspace's managed documents and one set of watchers, shared by
2
+ // the objects and activity readers. Nothing here writes.
3
+ import { createHash } from 'node:crypto';
4
+ import { watch } from 'node:fs';
5
+ import { readdir, readFile, stat } from 'node:fs/promises';
6
+ import { extname, isAbsolute, join, relative } from 'node:path';
7
+ import { resolveWorkspace } from './workspace.mjs';
8
+
9
+ export const KINDS = ['model', 'iteration', 'tool', 'data', 'result', 'memory'];
10
+
11
+ const SETTLE_MS = 150; // Quiet time after the last event before rescanning.
12
+ const MAX_WAIT_MS = 1000; // A steady stream of events still gets a rescan this often.
13
+ const ROOT_CHECK_MS = 2000; // Stores appear on first use, and a checkout can replace them.
14
+
15
+ export const revisionOf = (bytes) => createHash('sha256').update(bytes).digest('hex');
16
+
17
+ function scalar(text) {
18
+ if (text.startsWith('"')) {
19
+ try {
20
+ return JSON.parse(text);
21
+ } catch {
22
+ return text;
23
+ }
24
+ }
25
+ if (text.startsWith("'") && text.endsWith("'")) return text.slice(1, -1).replaceAll("''", "'");
26
+ return text;
27
+ }
28
+
29
+ // The Kernel's test for managed Memory: a frontmatter whose kind is memory. Only the
30
+ // top-level scalars are read here; links come from the index.
31
+ export function memoryFrontmatter(text) {
32
+ if (!text.startsWith('---\n')) return null;
33
+ const end = text.indexOf('\n---\n', 4);
34
+ if (end < 0) return /^kind: memory$/m.test(text) ? { kind: 'memory', problem: 'frontmatter is not closed' } : null;
35
+ const frontmatter = text.slice(4, end);
36
+ if (frontmatter.trimStart().startsWith('{')) {
37
+ try {
38
+ const meta = JSON.parse(frontmatter);
39
+ return meta?.kind === 'memory' ? meta : null;
40
+ } catch {
41
+ return /"kind"\s*:\s*"memory"/.test(frontmatter) ? { kind: 'memory', problem: 'frontmatter is not valid JSON' } : null;
42
+ }
43
+ }
44
+ const meta = {};
45
+ for (const line of frontmatter.split('\n')) {
46
+ const match = line.match(/^([A-Za-z_]+):[ \t]+(.+?)[ \t]*$/);
47
+ if (match) meta[match[1]] = scalar(match[2]);
48
+ }
49
+ return meta.kind === 'memory' ? meta : null;
50
+ }
51
+
52
+ function jsonSummary(kind, bytes) {
53
+ let doc;
54
+ try {
55
+ doc = JSON.parse(bytes.toString('utf8'));
56
+ } catch {
57
+ return { problem: 'not valid JSON' };
58
+ }
59
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) return { problem: 'not a JSON object' };
60
+ if (doc.kind !== kind) return { ...doc, problem: `kind is ${JSON.stringify(doc.kind ?? null)}, expected ${kind}` };
61
+ return doc;
62
+ }
63
+
64
+ const text = (value) => (typeof value === 'string' && value ? value : undefined);
65
+
66
+ function summarize(kind, path, rel, bytes) {
67
+ const meta = kind === 'memory' ? memoryFrontmatter(bytes.toString('utf8')) : jsonSummary(kind, bytes);
68
+ if (!meta) return null; // Ordinary Markdown under memory/.
69
+ return {
70
+ kind,
71
+ id: text(meta.id) ?? rel, // A document with no ID can still be listed and opened.
72
+ name: text(meta.name) ?? text(meta.title) ?? text(meta.activity),
73
+ aliases: Array.isArray(meta.aliases) ? meta.aliases.filter(text) : [],
74
+ status: text(meta.status),
75
+ path,
76
+ rel,
77
+ revision: revisionOf(bytes),
78
+ created_at: text(meta.created_at),
79
+ updated_at: text(meta.updated_at),
80
+ authored_at: text(meta.authored_at),
81
+ problem: meta.problem ?? (text(meta.id) ? undefined : 'no id'),
82
+ };
83
+ }
84
+
85
+ // A temporary sibling (.tmpXXXXXX) has no extension, so it never counts. Symlinks
86
+ // don't count either, as for the Kernel.
87
+ async function listFiles(dir, extension, out = []) {
88
+ let entries;
89
+ try {
90
+ entries = await readdir(dir, { withFileTypes: true });
91
+ } catch {
92
+ return out; // Absent on a fresh Workspace, or gone mid-scan.
93
+ }
94
+ for (const entry of entries) {
95
+ const path = join(dir, entry.name);
96
+ if (entry.isDirectory()) await listFiles(path, extension, out);
97
+ else if (entry.isFile() && extname(entry.name) === extension) out.push(path);
98
+ }
99
+ return out;
100
+ }
101
+
102
+ const stampOf = (info) => (info ? `${info.ino}:${info.size}:${info.mtimeMs}` : null);
103
+
104
+ const stores = new Map();
105
+
106
+ export function openStore(root) {
107
+ if (!stores.has(root)) stores.set(root, createStore(root));
108
+ return stores.get(root);
109
+ }
110
+
111
+ async function createStore(root) {
112
+ const workspace = await resolveWorkspace(root);
113
+ const { metadata, results, memory } = workspace.paths;
114
+ const stateDir = workspace.local_state?.directory;
115
+ const indexPath = workspace.local_state?.index;
116
+ const base = workspace.project_root ?? root;
117
+ const roots = {
118
+ model: join(metadata, 'models'),
119
+ iteration: join(metadata, 'iterations'),
120
+ tool: join(metadata, 'tools'),
121
+ data: join(metadata, 'data'),
122
+ result: results,
123
+ memory,
124
+ };
125
+
126
+ const relToBase = (path) => {
127
+ const rel = relative(base, path);
128
+ return rel.startsWith('..') || isAbsolute(rel) ? path : rel;
129
+ };
130
+
131
+ const store = {
132
+ root,
133
+ workspace,
134
+ docs: new Map(), // path -> summary
135
+ byId: new Map(),
136
+ byName: new Map(), // `${kind}:${name or alias}` -> summary, or null when ambiguous
137
+ receipts: [],
138
+ relToBase,
139
+ subscribe: (listener) => listeners.add(listener),
140
+ close,
141
+ };
142
+ const listeners = new Set();
143
+ const cache = new Map(); // path -> { stamp, doc }, so a rescan only reads what changed
144
+ let indexStamp = null;
145
+
146
+ async function scanDocuments() {
147
+ const docs = new Map();
148
+ const seen = new Set();
149
+ for (const kind of KINDS) {
150
+ for (const path of await listFiles(roots[kind], kind === 'memory' ? '.md' : '.json')) {
151
+ const stamp = stampOf(await stat(path).catch(() => null));
152
+ if (!stamp) continue;
153
+ let entry = cache.get(path);
154
+ if (entry?.stamp !== stamp) {
155
+ const bytes = await readFile(path).catch(() => null);
156
+ if (!bytes) continue;
157
+ entry = { stamp, doc: summarize(kind, path, relToBase(path), bytes) };
158
+ cache.set(path, entry);
159
+ }
160
+ seen.add(path);
161
+ if (entry.doc) docs.set(path, entry.doc);
162
+ }
163
+ }
164
+ for (const path of cache.keys()) if (!seen.has(path)) cache.delete(path);
165
+ return docs;
166
+ }
167
+
168
+ // Short-lived markers of a Kernel operation: publication receipts, and the Data and
169
+ // worktree operations that `accrete recover` also reads.
170
+ async function scanReceipts() {
171
+ if (!stateDir) return [];
172
+ const files = [];
173
+ for (const dir of ['receipts', 'operations']) await listFiles(join(stateDir, dir), '.json', files);
174
+ return files.map((path) => relative(stateDir, path)).sort();
175
+ }
176
+
177
+ function setDocs(docs) {
178
+ store.docs = docs;
179
+ store.byId = new Map();
180
+ store.byName = new Map();
181
+ for (const doc of docs.values()) {
182
+ store.byId.set(doc.id, doc);
183
+ for (const name of [doc.name, ...doc.aliases]) {
184
+ if (!name) continue;
185
+ const key = `${doc.kind}:${name}`;
186
+ store.byName.set(key, store.byName.has(key) ? null : doc);
187
+ }
188
+ }
189
+ }
190
+
191
+ async function rescan() {
192
+ const docs = await scanDocuments();
193
+ const receipts = await scanReceipts();
194
+ const stamp = indexPath ? stampOf(await stat(indexPath).catch(() => null)) : null;
195
+
196
+ const change = { added: [], changed: [], removed: [] };
197
+ for (const [path, doc] of docs) {
198
+ const before = store.docs.get(path);
199
+ if (!before) change.added.push(doc);
200
+ else if (before.revision !== doc.revision) change.changed.push({ before, after: doc });
201
+ }
202
+ for (const [path, doc] of store.docs) if (!docs.has(path)) change.removed.push(doc);
203
+ change.receiptsAdded = receipts.filter((name) => !store.receipts.includes(name));
204
+ change.receiptsRemoved = store.receipts.filter((name) => !receipts.includes(name));
205
+ change.indexChanged = stamp !== indexStamp;
206
+
207
+ setDocs(docs);
208
+ store.receipts = receipts;
209
+ indexStamp = stamp;
210
+
211
+ const documentsMoved = change.added.length || change.changed.length || change.removed.length;
212
+ const receiptsMoved = change.receiptsAdded.length || change.receiptsRemoved.length;
213
+ if (!documentsMoved && !receiptsMoved && !change.indexChanged) return;
214
+ await Promise.all([...listeners].map((listener) => listener(change)));
215
+ }
216
+
217
+ // Rescans never overlap: a change seen during one runs another straight after.
218
+ let timer = null;
219
+ let deadline = 0;
220
+ let running = false;
221
+ let again = false;
222
+
223
+ function schedule() {
224
+ const now = Date.now();
225
+ if (!timer) deadline = now + MAX_WAIT_MS;
226
+ clearTimeout(timer);
227
+ timer = setTimeout(run, Math.max(0, Math.min(SETTLE_MS, deadline - now)));
228
+ timer.unref();
229
+ }
230
+
231
+ async function run() {
232
+ timer = null;
233
+ if (running) {
234
+ again = true;
235
+ return;
236
+ }
237
+ running = true;
238
+ do {
239
+ again = false;
240
+ await rescan().catch((err) => console.error(`accrete-ui: document scan failed: ${err.message}`));
241
+ } while (again);
242
+ running = false;
243
+ }
244
+
245
+ // Watch whichever roots exist now, and re-attach when one appears or is replaced.
246
+ // Watching the whole local state directory takes in receipts/, operations/ and the index.
247
+ const watchers = new Map(); // dir -> { ino, watcher }
248
+ const watched = [metadata, results, memory, stateDir].filter(Boolean);
249
+
250
+ async function syncWatchers() {
251
+ for (const dir of watched) {
252
+ const info = await stat(dir).catch(() => null);
253
+ const current = watchers.get(dir);
254
+ if (current && current.ino === info?.ino) continue;
255
+ current?.watcher.close();
256
+ watchers.delete(dir);
257
+ if (current) schedule();
258
+ if (!info) continue;
259
+ const watcher = watch(dir, { recursive: true, persistent: false }, schedule);
260
+ watcher.on('error', () => {
261
+ watcher.close();
262
+ if (watchers.get(dir)?.watcher === watcher) watchers.delete(dir);
263
+ });
264
+ watchers.set(dir, { ino: info.ino, watcher });
265
+ schedule();
266
+ }
267
+ }
268
+
269
+ const rootCheck = setInterval(syncWatchers, ROOT_CHECK_MS);
270
+ rootCheck.unref();
271
+
272
+ function close() {
273
+ clearInterval(rootCheck);
274
+ clearTimeout(timer);
275
+ timer = null;
276
+ for (const { watcher } of watchers.values()) watcher.close();
277
+ watchers.clear();
278
+ listeners.clear();
279
+ stores.delete(root);
280
+ }
281
+
282
+ await rescan();
283
+ await syncWatchers();
284
+ return store;
285
+ }
@@ -0,0 +1,18 @@
1
+ // One SSE stream for the whole page. emit(event, data) sends a named event to
2
+ // every open page. The last value of each event name is replayed to a page that
3
+ // connects later, so emit whole state, not deltas.
4
+ const clients = new Set();
5
+ const latest = new Map();
6
+
7
+ export function emit(event, data) {
8
+ const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
9
+ latest.set(event, frame);
10
+ for (const res of clients) res.write(frame);
11
+ }
12
+
13
+ export function serveEvents(req, res) {
14
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store' });
15
+ for (const frame of latest.values()) res.write(frame);
16
+ clients.add(res);
17
+ req.on('close', () => clients.delete(res));
18
+ }
@@ -0,0 +1,110 @@
1
+ // The process table and open files, from ps and lsof. macOS has no /proc.
2
+ import { execFile } from 'node:child_process';
3
+
4
+ // Both exit non-zero when a listed pid has gone, and what they printed is still good.
5
+ function run(command, args) {
6
+ return new Promise((resolve) => {
7
+ execFile(command, args, { env: { ...process.env, LC_ALL: 'C' }, maxBuffer: 64 * 1024 * 1024, timeout: 15000 }, (_err, stdout) => {
8
+ resolve(stdout ?? '');
9
+ });
10
+ });
11
+ }
12
+
13
+ const PS_LINE = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\w{3} \w{3}\s+\d+ [\d:]+ \d{4})\s(.*)$/;
14
+
15
+ export function parsePs(text) {
16
+ const rows = [];
17
+ for (const line of text.split('\n')) {
18
+ const m = PS_LINE.exec(line);
19
+ if (!m) continue;
20
+ rows.push({
21
+ uid: Number(m[1]),
22
+ pid: Number(m[2]),
23
+ ppid: Number(m[3]),
24
+ cpu: Number(m[4]),
25
+ rss: Number(m[5]) * 1024,
26
+ state: m[6],
27
+ tty: m[7],
28
+ startedAt: Date.parse(m[8]),
29
+ command: m[9].trim(),
30
+ });
31
+ }
32
+ return rows;
33
+ }
34
+
35
+ let lastTable = null;
36
+
37
+ // A caller that does not need the very latest can share the table another poll just read.
38
+ export async function listProcesses({ maxAgeMs = 0 } = {}) {
39
+ if (lastTable && Date.now() - lastTable.at <= maxAgeMs) return lastTable.rows;
40
+ const text = await run('ps', ['-axww', '-o', 'uid=,pid=,ppid=,pcpu=,rss=,state=,tty=,lstart=,command=']);
41
+ lastTable = { at: Date.now(), rows: parsePs(text) };
42
+ return lastTable.rows;
43
+ }
44
+
45
+ // lsof writes non-ASCII bytes of a name as \xHH under LC_ALL=C.
46
+ function decodeName(name) {
47
+ if (!name.includes('\\x')) return name;
48
+ const bytes = name.replace(/\\x([0-9a-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
49
+ return Buffer.from(bytes, 'latin1').toString('utf8');
50
+ }
51
+
52
+ // Output of `lsof -F`, one field per line, a p line opening each process and an
53
+ // f line opening each of its files. Returns pid -> [{ fd, access, type, size, name }].
54
+ export function parseLsof(text) {
55
+ const byPid = new Map();
56
+ let files = null;
57
+ let file = null;
58
+ for (const line of text.split('\n')) {
59
+ const value = line.slice(1);
60
+ switch (line[0]) {
61
+ case 'p':
62
+ files = [];
63
+ byPid.set(Number(value), files);
64
+ break;
65
+ case 'f':
66
+ file = { fd: value };
67
+ files?.push(file);
68
+ break;
69
+ case 'a':
70
+ if (file) file.access = value;
71
+ break;
72
+ case 't':
73
+ if (file) file.type = value;
74
+ break;
75
+ case 's':
76
+ if (file) file.size = Number(value);
77
+ break;
78
+ case 'n':
79
+ if (file) file.name = decodeName(value);
80
+ break;
81
+ }
82
+ }
83
+ return byPid;
84
+ }
85
+
86
+ // pid -> working directory, for the given pids only.
87
+ export async function workingDirectories(pids) {
88
+ const cwds = new Map();
89
+ if (pids.length === 0) return cwds;
90
+ const text = await run('lsof', ['-nP', '-a', '-d', 'cwd', '-p', pids.join(','), '-Fpn']);
91
+ for (const [pid, files] of parseLsof(text)) {
92
+ if (files[0]?.name) cwds.set(pid, files[0].name);
93
+ }
94
+ return cwds;
95
+ }
96
+
97
+ // pid -> open files, for the given pids only.
98
+ export async function openFiles(pids) {
99
+ if (pids.length === 0) return new Map();
100
+ return parseLsof(await run('lsof', ['-nP', '-p', pids.join(','), '-Fpfatsn']));
101
+ }
102
+
103
+ // Which agent a process is, by its executable. Processes retitle themselves, so
104
+ // `claude` may be all the table shows.
105
+ export function agentOf(command) {
106
+ const m =
107
+ /^(?:\S*\/)?(?:node\s+(?:\S*\/)?)?(claude|codex)(?:\s|$)/.exec(command) ??
108
+ /^(?:\S*\/)?node\s+\S*\/(claude)-code\/cli\.m?js(?:\s|$)/.exec(command);
109
+ return m ? m[1] : null;
110
+ }
@@ -0,0 +1,134 @@
1
+ // Reads the Workspace for the activity panel: a time-ordered feed of what was seen in
2
+ // documents, local state and Git. It says what changed, never who changed it. The feed
3
+ // lives only in this process.
4
+ import { execFile } from 'node:child_process';
5
+ import { openStore } from '../documents.mjs';
6
+
7
+ const CAP = 500;
8
+ const GIT_POLL_MS = 3000;
9
+ const COMMITS = 50;
10
+
11
+ const short = (revision) => revision.slice(0, 8);
12
+
13
+ function git(root, args) {
14
+ return new Promise((resolve) => {
15
+ execFile('git', ['--no-optional-locks', '-C', root, ...args], { timeout: 10000, maxBuffer: 2 ** 26 }, (err, stdout) => resolve(err ? null : stdout));
16
+ });
17
+ }
18
+
19
+ function parseStatus(text) {
20
+ const state = { repo: true, branch: null, upstream: null, ahead: 0, behind: 0, dirty: 0 };
21
+ for (const line of text.split('\n')) {
22
+ if (!line) continue;
23
+ if (!line.startsWith('# ')) state.dirty += 1;
24
+ else if (line.startsWith('# branch.head ')) state.branch = line.slice('# branch.head '.length);
25
+ else if (line.startsWith('# branch.upstream ')) state.upstream = line.slice('# branch.upstream '.length);
26
+ else if (line.startsWith('# branch.ab ')) {
27
+ const [, ahead, behind] = line.match(/\+(\d+) -(\d+)/) ?? [];
28
+ state.ahead = Number(ahead);
29
+ state.behind = Number(behind);
30
+ }
31
+ }
32
+ return state;
33
+ }
34
+
35
+ function parseWorktrees(text) {
36
+ const worktrees = [];
37
+ for (const line of (text ?? '').split('\n')) {
38
+ if (line.startsWith('worktree ')) worktrees.push({ path: line.slice('worktree '.length), branch: null });
39
+ else if (line.startsWith('branch ')) worktrees.at(-1).branch = line.slice('branch '.length).replace('refs/heads/', '');
40
+ }
41
+ return worktrees;
42
+ }
43
+
44
+ // All local branches, so a commit made in an iteration's worktree shows too.
45
+ async function readCommits(root) {
46
+ const out = await git(root, ['log', `-n${COMMITS}`, '--branches', 'HEAD', '--format=%H%x00%h%x00%cI%x00%an%x00%s']);
47
+ if (!out) return [];
48
+ return out
49
+ .split('\n')
50
+ .filter(Boolean)
51
+ .map((line) => {
52
+ const [sha, abbreviated, committed_at, author, subject] = line.split('\0');
53
+ return { sha, abbreviated, committed_at, author, subject };
54
+ });
55
+ }
56
+
57
+ function backfill(doc) {
58
+ const items = [];
59
+ const add = (field, verb) => {
60
+ const at = Date.parse(doc[field]);
61
+ if (!Number.isNaN(at)) items.push({ at, source: 'document', text: `${doc.rel} ${verb} (${field})`, detail: doc.name, open: doc.id });
62
+ };
63
+ add('created_at', 'created');
64
+ add('updated_at', 'updated');
65
+ add('authored_at', 'authored');
66
+ return items;
67
+ }
68
+
69
+ const commitItem = (commit, at, live) => ({
70
+ at,
71
+ live,
72
+ source: 'git',
73
+ text: `commit ${commit.abbreviated} ${commit.subject}`,
74
+ detail: commit.author,
75
+ committed_at: commit.committed_at,
76
+ });
77
+
78
+ function documentItems(change, at) {
79
+ const item = (doc, text) => ({ at, live: true, source: 'document', text, detail: doc.name, open: doc.id });
80
+ return [
81
+ ...change.added.map((doc) => item(doc, `${doc.rel} added, revision ${short(doc.revision)}`)),
82
+ ...change.changed.map(({ before, after }) => item(after, `${after.rel} changed, revision ${short(before.revision)} to ${short(after.revision)}`)),
83
+ ...change.removed.map((doc) => item(doc, `${doc.rel} removed, was revision ${short(doc.revision)}`)),
84
+ ...change.receiptsAdded.map((name) => ({ at, live: true, source: 'receipt', text: `${name} appeared in local state` })),
85
+ ...change.receiptsRemoved.map((name) => ({ at, live: true, source: 'receipt', text: `${name} gone from local state` })),
86
+ ];
87
+ }
88
+
89
+ export async function start({ root, emit }) {
90
+ const store = await openStore(root);
91
+ let feed = [];
92
+ let gitState = null;
93
+ const seenCommits = new Set();
94
+
95
+ const add = (items) => {
96
+ feed = [...feed, ...items].sort((a, b) => b.at - a.at).slice(0, CAP);
97
+ };
98
+ const publish = () => emit('activity', { feed, git: gitState, receipts: store.receipts });
99
+
100
+ add([...store.docs.values()].flatMap(backfill));
101
+ store.subscribe((change) => {
102
+ const items = documentItems(change, Date.now());
103
+ if (!items.length) return;
104
+ add(items);
105
+ publish();
106
+ });
107
+
108
+ // A commit from the first look is history, dated by Git. A later one is placed when
109
+ // it was seen, since a merge or pull can bring in old dates.
110
+ async function poll(first) {
111
+ const status = await git(root, ['status', '--porcelain=v2', '--branch']);
112
+ const next = status === null ? { repo: false } : parseStatus(status);
113
+ if (next.repo) next.worktrees = parseWorktrees(await git(root, ['worktree', 'list', '--porcelain']));
114
+
115
+ const fresh = (next.repo ? await readCommits(root) : []).filter((commit) => !seenCommits.has(commit.sha));
116
+ for (const commit of fresh) seenCommits.add(commit.sha);
117
+ const items = fresh.map((commit) => (first ? commitItem(commit, Date.parse(commit.committed_at), false) : commitItem(commit, Date.now(), true)));
118
+ if (items.length) add(items);
119
+
120
+ const gitChanged = JSON.stringify(next) !== JSON.stringify(gitState);
121
+ gitState = next;
122
+ if (first || items.length || gitChanged) publish();
123
+ }
124
+
125
+ function loop() {
126
+ setTimeout(async () => {
127
+ await poll(false).catch((err) => console.error(`accrete-ui: git poll failed: ${err.message}`));
128
+ loop();
129
+ }, GIT_POLL_MS).unref();
130
+ }
131
+
132
+ await poll(true);
133
+ loop();
134
+ }
@@ -0,0 +1,45 @@
1
+ // Agent sessions for this Workspace, from transcripts, session files and the process table.
2
+ import { homedir } from 'node:os';
3
+ import { createSessionIndex } from '../agent-sessions.mjs';
4
+ import { agentOf, listProcesses, openFiles, workingDirectories } from '../processes.mjs';
5
+ import { readScope } from '../scope.mjs';
6
+
7
+ const POLL_MS = 5000;
8
+ const SCOPE_MS = 30000;
9
+ const PS_SHARE_MS = 4500; // the running panel reads the same table every 4 s
10
+
11
+ export async function snapshot(index, scope) {
12
+ const table = await listProcesses({ maxAgeMs: PS_SHARE_MS });
13
+ const agents = table.filter((row) => agentOf(row.command));
14
+ const [cwds, open] = await Promise.all([
15
+ workingDirectories(agents.map((row) => row.pid)),
16
+ openFiles(agents.filter((row) => agentOf(row.command) === 'codex').map((row) => row.pid)),
17
+ ]);
18
+ return { ...(await index.update({ scope, table, cwds, open })), home: homedir() };
19
+ }
20
+
21
+ export function start({ root, emit }) {
22
+ emit('agents', { root, scope: [root], sessions: [], unattached: [], loading: true, home: homedir() });
23
+ const index = createSessionIndex();
24
+ let scope = [root];
25
+ let scopeAt = 0;
26
+ let last = null;
27
+
28
+ async function poll() {
29
+ try {
30
+ if (Date.now() - scopeAt > SCOPE_MS) {
31
+ scope = await readScope(root);
32
+ scopeAt = Date.now();
33
+ }
34
+ const state = JSON.stringify(await snapshot(index, scope));
35
+ if (state !== last) {
36
+ last = state;
37
+ emit('agents', JSON.parse(state));
38
+ }
39
+ } catch (err) {
40
+ console.error(`accrete-ui: agents poll failed: ${err.message}`);
41
+ }
42
+ setTimeout(poll, POLL_MS).unref();
43
+ }
44
+ poll();
45
+ }