openzoo 0.48.99 → 0.49.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.
- package/lib/grokui.mjs +68 -16
- package/lib/worktree.mjs +424 -0
- package/package.json +2 -1
package/lib/grokui.mjs
CHANGED
|
@@ -20,6 +20,10 @@ import {
|
|
|
20
20
|
subscriptionPublicView, parseSubscriptionPaste,
|
|
21
21
|
billingTiers, billingCheckout, fetchBillingKey, ingestBillingKeyResponse,
|
|
22
22
|
} from './subscription.js';
|
|
23
|
+
import {
|
|
24
|
+
prepareChildDir, finishChildDir, lockWorktree, unlockWorktree,
|
|
25
|
+
parsePrRef, fetchSpecsForOrigin, agentSlug,
|
|
26
|
+
} from './worktree.mjs';
|
|
23
27
|
|
|
24
28
|
const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
|
|
25
29
|
// BIND HOST. Default 127.0.0.1 so the desktop app never exposes a shell-capable
|
|
@@ -446,9 +450,28 @@ function loadThreads() {
|
|
|
446
450
|
} catch { return false; }
|
|
447
451
|
}
|
|
448
452
|
|
|
449
|
-
function
|
|
453
|
+
function attachChildDir(t, parent, spec) {
|
|
454
|
+
// Never copy parent.dir — that is the testingcluade bug: every tetris kid
|
|
455
|
+
// sat in the parent's checkout and collided. Isolated worktree (git) or
|
|
456
|
+
// ~/.openzoo/grokui-worktrees/<slug> (not git). Same Node process; cwd is t.dir.
|
|
457
|
+
if (t.dir && t.worktree?.path && existsSync(t.dir)) return t;
|
|
458
|
+
const ws = prepareChildDir(parent, t.name, spec);
|
|
459
|
+
t.dir = ws.path;
|
|
460
|
+
t.worktree = {
|
|
461
|
+
path: ws.path,
|
|
462
|
+
branch: ws.branch || null,
|
|
463
|
+
parentDir: ws.parentDir,
|
|
464
|
+
kind: ws.kind,
|
|
465
|
+
repo: ws.repo || null,
|
|
466
|
+
fetchRef: ws.fetchRef || '',
|
|
467
|
+
baseRef: ws.baseRef || '',
|
|
468
|
+
};
|
|
469
|
+
return t;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function newThread(name, parent, members, spec) {
|
|
450
473
|
const id = randomUUID();
|
|
451
|
-
// A subagent INHERITS its parent's run mode
|
|
474
|
+
// A subagent INHERITS its parent's run mode and model — not its directory.
|
|
452
475
|
//
|
|
453
476
|
// runMode especially: children defaulted to 'ask', so a bot spawned in auto
|
|
454
477
|
// mode emitted a RUN, the harness parked it awaiting approval, and nobody
|
|
@@ -456,8 +479,8 @@ function newThread(name, parent, members) {
|
|
|
456
479
|
// typing…" forever while the parent reported it as working. Spawning from
|
|
457
480
|
// auto and landing in ask is never what the user meant.
|
|
458
481
|
//
|
|
459
|
-
// dir
|
|
460
|
-
//
|
|
482
|
+
// dir is isolated per child (git worktree or ~/.openzoo/grokui-worktrees).
|
|
483
|
+
// Copying p.dir put every SPAWN in /Users/stacc/testingcluade.
|
|
461
484
|
//
|
|
462
485
|
// tier/race/raceMode for the same reason, and one more: they are the SPEND
|
|
463
486
|
// dial. Setting a project to the expensive tier and then having every
|
|
@@ -470,11 +493,11 @@ function newThread(name, parent, members) {
|
|
|
470
493
|
messages: members ? null : [{ role: 'system', content: SYSTEM }],
|
|
471
494
|
members: members || null, history: [], status: 'idle', createdAt: Date.now(), lastActivityAt: Date.now(),
|
|
472
495
|
...(p?.runMode ? { runMode: p.runMode } : {}),
|
|
473
|
-
...(p?.dir ? { dir: p.dir } : {}),
|
|
474
496
|
...(p?.model ? { model: p.model } : {}),
|
|
475
497
|
...(p?.tier ? { tier: p.tier } : {}),
|
|
476
498
|
...(p?.race ? { race: p.race } : {}),
|
|
477
499
|
...(p?.raceNeed ? { raceNeed: p.raceNeed } : {}) };
|
|
500
|
+
if (p) attachChildDir(t, p, spec);
|
|
478
501
|
threads.set(id, t);
|
|
479
502
|
saveThreads();
|
|
480
503
|
return t;
|
|
@@ -1333,6 +1356,7 @@ setInterval(() => {
|
|
|
1333
1356
|
try { turnAborts.get(t)?.abort(); } catch { /* none */ }
|
|
1334
1357
|
t.status = 'idle';
|
|
1335
1358
|
t.liveStatus = '';
|
|
1359
|
+
unlockWorktree(t);
|
|
1336
1360
|
dirty = true;
|
|
1337
1361
|
}
|
|
1338
1362
|
if (dirty) saveThreads();
|
|
@@ -1349,7 +1373,7 @@ setInterval(() => {
|
|
|
1349
1373
|
const PARALLEL_DIRECTIVE = /^[ \t>*-]*(READ|LS|GLOB|GREP|FETCH|PEEK|MCP):[ \t]*(.+)$/gm;
|
|
1350
1374
|
|
|
1351
1375
|
// Walk a thread dir once, cheaply, skipping the things nobody means to search.
|
|
1352
|
-
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '__pycache__', '.venv', 'venv']);
|
|
1376
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '__pycache__', '.venv', 'venv', '.openzoo']);
|
|
1353
1377
|
function walkDir(base, rel = '', out = [], depth = 0) {
|
|
1354
1378
|
if (depth > 12 || out.length > 5000) return out;
|
|
1355
1379
|
let entries = [];
|
|
@@ -1610,7 +1634,7 @@ function siblingJob(sib) {
|
|
|
1610
1634
|
return job.trim().replace(/\s+/g, ' ').slice(0, 400);
|
|
1611
1635
|
}
|
|
1612
1636
|
|
|
1613
|
-
function spawnBrief(parent, { refresh = false } = {}) {
|
|
1637
|
+
function spawnBrief(parent, { refresh = false, child } = {}) {
|
|
1614
1638
|
if (!parent) return '';
|
|
1615
1639
|
const rootId = rootOf(parent).rootId;
|
|
1616
1640
|
const root = threads.get(rootId) || parent;
|
|
@@ -1618,7 +1642,8 @@ function spawnBrief(parent, { refresh = false } = {}) {
|
|
|
1618
1642
|
const latest = String(lastUserAsk(parent)?.text || '').trim();
|
|
1619
1643
|
const recent = (parent.history || []).filter((m) => !isHarnessUserText(m.text)).slice(-8);
|
|
1620
1644
|
const siblings = [...threads.values()].filter((x) => x.parent === parent.id);
|
|
1621
|
-
const cwd =
|
|
1645
|
+
const cwd = child?.dir || WORKSPACE_DIR;
|
|
1646
|
+
const branch = child?.worktree?.branch;
|
|
1622
1647
|
const mode = parent.runMode || 'ask';
|
|
1623
1648
|
const tier = parent.tier || 'medium';
|
|
1624
1649
|
const race = Number(parent.race) || 0;
|
|
@@ -1646,6 +1671,7 @@ function spawnBrief(parent, { refresh = false } = {}) {
|
|
|
1646
1671
|
}
|
|
1647
1672
|
lines.push('', 'WORKING SET:');
|
|
1648
1673
|
lines.push('cwd: ' + cwd);
|
|
1674
|
+
if (branch) lines.push('branch: ' + branch);
|
|
1649
1675
|
lines.push('run mode: ' + mode);
|
|
1650
1676
|
lines.push('tier: ' + tier
|
|
1651
1677
|
+ (race >= 2 ? ' · race ' + (raceNeed > 1 ? raceNeed + ' of ' + race : race) : '')
|
|
@@ -1662,7 +1688,8 @@ function spawnBrief(parent, { refresh = false } = {}) {
|
|
|
1662
1688
|
}
|
|
1663
1689
|
|
|
1664
1690
|
function childKickoff(parent, childName, task, { fresh = true } = {}) {
|
|
1665
|
-
|
|
1691
|
+
const child = findByName(childName);
|
|
1692
|
+
return spawnBrief(parent, { refresh: !fresh, child }) + task + spawnPosition(parent, childName);
|
|
1666
1693
|
}
|
|
1667
1694
|
|
|
1668
1695
|
/**
|
|
@@ -1684,6 +1711,7 @@ function spawnPosition(parent, childName) {
|
|
|
1684
1711
|
if (!parent) return '';
|
|
1685
1712
|
const depth = rootOf(parent).depth + 1;
|
|
1686
1713
|
const others = [...threads.values()].filter((x) => x.parent === parent.id && x.name !== childName);
|
|
1714
|
+
const me = findByName(childName);
|
|
1687
1715
|
return '\n\n--- your place in the team ---\n'
|
|
1688
1716
|
+ `You are "${childName}", spawned by "${parent.name}". You are at tier ${depth} of this project.\n`
|
|
1689
1717
|
+ (others.length
|
|
@@ -1695,8 +1723,12 @@ function spawnPosition(parent, childName) {
|
|
|
1695
1723
|
+ 'that is how a team becomes sixteen bots and zero artifacts. SPAWN only if your slice '
|
|
1696
1724
|
+ 'contains genuinely independent work that NO existing sibling covers, and name any agent '
|
|
1697
1725
|
+ 'you do spawn after what it owns, never "agent-1".\n'
|
|
1698
|
-
+
|
|
1699
|
-
|
|
1726
|
+
+ (me?.dir
|
|
1727
|
+
? `Your working directory is ${me.dir}`
|
|
1728
|
+
+ (me.worktree?.branch ? ` on ${me.worktree.branch}` : '')
|
|
1729
|
+
+ ' — isolated from the parent checkout. Write here, not in the parent tree.\n'
|
|
1730
|
+
: '')
|
|
1731
|
+
+ 'Everything else — build it yourself, now.\n';
|
|
1700
1732
|
}
|
|
1701
1733
|
|
|
1702
1734
|
/**
|
|
@@ -1809,10 +1841,15 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1809
1841
|
const notes = [];
|
|
1810
1842
|
for (const { name, task } of parsed) {
|
|
1811
1843
|
const existing = findByName(name);
|
|
1812
|
-
if (existing) {
|
|
1844
|
+
if (existing) {
|
|
1845
|
+
attachChildDir(existing, parent, task);
|
|
1846
|
+
notes.push(`${name} already exists — woke it to keep working.`);
|
|
1847
|
+
made.push({ t: existing, task, fresh: false });
|
|
1848
|
+
continue;
|
|
1849
|
+
}
|
|
1813
1850
|
const siblings = [...threads.values()].filter((x) => x.parent === originId).length;
|
|
1814
1851
|
if (siblings >= SPAWN_MAX_CHILDREN) { notes.push(`Not spawning "${name}": already at ${SPAWN_MAX_CHILDREN} subagents.`); continue; }
|
|
1815
|
-
made.push({ t: newThread(name, originId), task, fresh: true });
|
|
1852
|
+
made.push({ t: newThread(name, originId, undefined, task), task, fresh: true });
|
|
1816
1853
|
}
|
|
1817
1854
|
// Every thread now exists, so spawnPosition sees the COMPLETE cohort.
|
|
1818
1855
|
for (const { t: sub, task, fresh } of made) {
|
|
@@ -1839,6 +1876,7 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1839
1876
|
// Repeat SPAWN is a wake, not a CONTEXT REFRESH. childKickoff({fresh:false})
|
|
1840
1877
|
// restates the original job and tells the child it already exists — MEASURED,
|
|
1841
1878
|
// the crew flipped to that preview, thought once, and sat.
|
|
1879
|
+
attachChildDir(existing, threads.get(originId), task);
|
|
1842
1880
|
wakeOnPing(existing);
|
|
1843
1881
|
return `${name} already exists — woke it to keep working.`;
|
|
1844
1882
|
}
|
|
@@ -1850,7 +1888,7 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1850
1888
|
+ `(limit ${SPAWN_MAX_CHILDREN}). Reuse one with SEND: <name> | <task> — `
|
|
1851
1889
|
+ `every live subagent costs paid calls.`;
|
|
1852
1890
|
}
|
|
1853
|
-
const sub = newThread(name, originId);
|
|
1891
|
+
const sub = newThread(name, originId, undefined, task);
|
|
1854
1892
|
// The child gets the ORIGINATING brief plus its own job — see spawnBrief.
|
|
1855
1893
|
kickTurn(sub.id, childKickoff(threads.get(originId), name, task)).catch(() => {}); // fire and forget
|
|
1856
1894
|
return `Spawned ${name} — working on it.`;
|
|
@@ -1892,7 +1930,7 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1892
1930
|
return `Cannot create "${name}": this thread already has ${siblings} subagents `
|
|
1893
1931
|
+ `(limit ${SPAWN_MAX_CHILDREN}). Reuse one with SEND: <existing name> | <task>.`;
|
|
1894
1932
|
}
|
|
1895
|
-
const sub = newThread(name, originId);
|
|
1933
|
+
const sub = newThread(name, originId, undefined, msg);
|
|
1896
1934
|
kickTurn(sub.id, childKickoff(threads.get(originId), name, msg)).catch(() => {});
|
|
1897
1935
|
return `${name} did not exist — spawned it with that message as its task.`;
|
|
1898
1936
|
}
|
|
@@ -1931,6 +1969,15 @@ async function tryDirective(reply, originId, onEvent) {
|
|
|
1931
1969
|
wakeOnPing(target);
|
|
1932
1970
|
return `${name}: pinged, working`;
|
|
1933
1971
|
}
|
|
1972
|
+
const done = /^[ \t>*-]*DONE:\s*/m.exec(reply);
|
|
1973
|
+
if (done) {
|
|
1974
|
+
const t = threads.get(originId);
|
|
1975
|
+
if (!t) return 'Done.';
|
|
1976
|
+
const result = finishChildDir(t);
|
|
1977
|
+
if (result.removed) return 'Done — clean worktree removed.';
|
|
1978
|
+
if (result.kept) return 'Done — worktree kept (has local work).';
|
|
1979
|
+
return 'Done.';
|
|
1980
|
+
}
|
|
1934
1981
|
const peek = /^[ \t>*-]*PEEK:\s*(.+)/m.exec(reply);
|
|
1935
1982
|
if (peek) {
|
|
1936
1983
|
const name = peek[1].trim();
|
|
@@ -2333,6 +2380,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2333
2380
|
try { turnAborts.get(t)?.abort(); } catch { /* none */ }
|
|
2334
2381
|
const turnAbort = new AbortController();
|
|
2335
2382
|
turnAborts.set(t, turnAbort);
|
|
2383
|
+
lockWorktree(t);
|
|
2336
2384
|
const raceN = Math.min(Number(t.race) || 0, 4);
|
|
2337
2385
|
const raceNeed = Math.min(Math.max(Number(t.raceNeed) || 1, 1), raceN || 1);
|
|
2338
2386
|
t.liveStatus = (!t.model && raceN >= 2) ? formatRaceStatus(0, raceNeed) : 'waiting on model…';
|
|
@@ -2605,6 +2653,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2605
2653
|
if (stillMine() && !chained && !parked && !t.pendingRun) {
|
|
2606
2654
|
t.status = 'idle';
|
|
2607
2655
|
t.liveStatus = '';
|
|
2656
|
+
unlockWorktree(t);
|
|
2608
2657
|
}
|
|
2609
2658
|
t.lastActivityAt = Date.now();
|
|
2610
2659
|
saveThreads();
|
|
@@ -5171,6 +5220,8 @@ const server = http.createServer((req, res) => {
|
|
|
5171
5220
|
return;
|
|
5172
5221
|
}
|
|
5173
5222
|
if (req.method === 'DELETE' && req.url.startsWith('/threads/')) {
|
|
5223
|
+
const doomed = threads.get(req.url.split('/')[2]);
|
|
5224
|
+
if (doomed) finishChildDir(doomed);
|
|
5174
5225
|
threads.delete(req.url.split('/')[2]);
|
|
5175
5226
|
saveThreads();
|
|
5176
5227
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
@@ -5305,5 +5356,6 @@ export {
|
|
|
5305
5356
|
tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
|
|
5306
5357
|
parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
|
|
5307
5358
|
handleSlash, newThread, setRunTurnForTest, AUTO_CONTINUE, pingWakeText, pingCanWake,
|
|
5308
|
-
childKickoff,
|
|
5359
|
+
childKickoff, findByName, attachChildDir, finishChildDir, lockWorktree, unlockWorktree,
|
|
5360
|
+
parsePrRef, fetchSpecsForOrigin, agentSlug,
|
|
5309
5361
|
};
|
package/lib/worktree.mjs
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
// Desktop grokui SPAWN isolation — Claude Code worktrees + UltraCode
|
|
2
|
+
// spawn_agent_worktree. Not ported to iOS/Android/Seeker/PSG1 (no local FS).
|
|
3
|
+
//
|
|
4
|
+
// Git goes through dugite's bundled binary, never PATH `git`.
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import {
|
|
7
|
+
appendFileSync, cpSync, existsSync, mkdirSync, readdirSync,
|
|
8
|
+
readFileSync, rmSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { setupEnvironment } from 'dugite';
|
|
13
|
+
|
|
14
|
+
const FETCH_MS = 8000;
|
|
15
|
+
const GIT_MS = 15000;
|
|
16
|
+
|
|
17
|
+
export function agentSlug(name) {
|
|
18
|
+
const pr = parsePrRef(name);
|
|
19
|
+
if (pr) return `pr-${pr.n}`;
|
|
20
|
+
const s = String(name || 'agent').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
|
|
21
|
+
return s || 'agent';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** #123, GitHub PR URL, GitLab MR URL, or a generic /pull/N. */
|
|
25
|
+
export function parsePrRef(text) {
|
|
26
|
+
const s = String(text || '').trim();
|
|
27
|
+
if (!s) return null;
|
|
28
|
+
const hash = /^#(\d+)\b/.exec(s);
|
|
29
|
+
if (hash) return { n: Number(hash[1]) };
|
|
30
|
+
const gh = /github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/i.exec(s);
|
|
31
|
+
if (gh) return { n: Number(gh[1]), host: 'github.com' };
|
|
32
|
+
const gl = /gitlab\.com\/\S+\/-\/merge_requests\/(\d+)/i.exec(s);
|
|
33
|
+
if (gl) return { n: Number(gl[1]), host: 'gitlab.com' };
|
|
34
|
+
const pull = /\/pull\/(\d+)\b/i.exec(s);
|
|
35
|
+
if (pull) return { n: Number(pull[1]) };
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function extractPrFromSpawn(name, spec) {
|
|
40
|
+
const fromName = parsePrRef(name);
|
|
41
|
+
if (fromName) return fromName;
|
|
42
|
+
const body = String(spec || '').trim();
|
|
43
|
+
if (!body) return null;
|
|
44
|
+
const first = body.split(/\s+/)[0];
|
|
45
|
+
return parsePrRef(first) || parsePrRef(body.split('|')[0].trim()) || parsePrRef(body);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** github.com → pull/N/head; gitlab.com → merge-requests/N/head; else pull first. */
|
|
49
|
+
export function fetchSpecsForOrigin(originUrl, n) {
|
|
50
|
+
const url = String(originUrl || '');
|
|
51
|
+
if (/github\.com/i.test(url)) return [`pull/${n}/head`];
|
|
52
|
+
if (/gitlab\.com/i.test(url)) return [`merge-requests/${n}/head`];
|
|
53
|
+
return [`pull/${n}/head`, `merge-requests/${n}/head`];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Absolute dugite binary, or '' if the embed is missing. Never PATH `git`. */
|
|
57
|
+
export function bundledGitPath() {
|
|
58
|
+
try {
|
|
59
|
+
const { gitLocation } = setupEnvironment({});
|
|
60
|
+
return gitLocation && existsSync(gitLocation) ? gitLocation : '';
|
|
61
|
+
} catch {
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveGitRunner() {
|
|
67
|
+
let gitLocation = '';
|
|
68
|
+
let env = {};
|
|
69
|
+
try {
|
|
70
|
+
const setup = setupEnvironment({
|
|
71
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
72
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
73
|
+
});
|
|
74
|
+
gitLocation = setup.gitLocation;
|
|
75
|
+
env = setup.env;
|
|
76
|
+
} catch { /* setup failed */ }
|
|
77
|
+
if (!gitLocation || !existsSync(gitLocation)) {
|
|
78
|
+
throw new Error('dugite embedded git is missing — SPAWN will not call PATH git');
|
|
79
|
+
}
|
|
80
|
+
return { bin: gitLocation, env, bundled: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let lastGitBinary = '';
|
|
84
|
+
export function gitBinary() { return lastGitBinary; }
|
|
85
|
+
|
|
86
|
+
/** Dugite bundled git only. Never exec `git` from PATH. */
|
|
87
|
+
export function git(args, cwd, { allowFail = false, timeout = GIT_MS } = {}) {
|
|
88
|
+
let bin, env;
|
|
89
|
+
try {
|
|
90
|
+
({ bin, env } = resolveGitRunner());
|
|
91
|
+
} catch (e) {
|
|
92
|
+
if (allowFail) return '';
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
lastGitBinary = bin;
|
|
96
|
+
try {
|
|
97
|
+
return execFileSync(bin, ['-c', 'safe.directory=*', ...args], {
|
|
98
|
+
cwd,
|
|
99
|
+
env,
|
|
100
|
+
encoding: 'utf8',
|
|
101
|
+
timeout,
|
|
102
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
103
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
104
|
+
}).trim();
|
|
105
|
+
} catch (e) {
|
|
106
|
+
if (allowFail) return '';
|
|
107
|
+
const err = new Error((e.stderr || e.message || '').toString().trim() || 'git failed');
|
|
108
|
+
err.code = e.status;
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function githubRepoFromOrigin(originUrl) {
|
|
114
|
+
const m = /github\.com[:/]([^/]+)\/([^/.]+)/i.exec(String(originUrl || ''));
|
|
115
|
+
return m ? { owner: m[1], repo: m[2] } : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function githubPullApiUrl(originUrl, n) {
|
|
119
|
+
const r = githubRepoFromOrigin(originUrl);
|
|
120
|
+
return r ? `https://api.github.com/repos/${r.owner}/${r.repo}/pulls/${n}` : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function gitlabProjectFromOrigin(originUrl) {
|
|
124
|
+
const m = /gitlab\.com[:/](.+?)(?:\.git)?$/i.exec(String(originUrl || '').replace(/\/+$/, ''));
|
|
125
|
+
return m ? m[1].replace(/\.git$/i, '') : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function gitlabMrApiUrl(originUrl, n) {
|
|
129
|
+
const project = gitlabProjectFromOrigin(originUrl);
|
|
130
|
+
return project
|
|
131
|
+
? `https://gitlab.com/api/v4/projects/${encodeURIComponent(project)}/merge_requests/${n}`
|
|
132
|
+
: null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function httpGetJsonSync(url) {
|
|
136
|
+
const src = `
|
|
137
|
+
const r = await fetch(${JSON.stringify(url)}, {
|
|
138
|
+
headers: { 'user-agent': 'openzoo-grokui', accept: 'application/json' },
|
|
139
|
+
});
|
|
140
|
+
if (!r.ok) process.exit(1);
|
|
141
|
+
process.stdout.write(await r.text());
|
|
142
|
+
`;
|
|
143
|
+
const body = execFileSync(process.execPath, ['--input-type=module', '-e', src], {
|
|
144
|
+
encoding: 'utf8',
|
|
145
|
+
timeout: FETCH_MS,
|
|
146
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
147
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
148
|
+
});
|
|
149
|
+
return JSON.parse(body);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** GitHub/GitLab API → commit SHA when `git fetch pull/N/head` cannot. */
|
|
153
|
+
export function fetchPrHeadShaViaApi(originUrl, n) {
|
|
154
|
+
const gh = githubPullApiUrl(originUrl, n);
|
|
155
|
+
if (gh) {
|
|
156
|
+
try {
|
|
157
|
+
const j = httpGetJsonSync(gh);
|
|
158
|
+
if (j?.head?.sha) return String(j.head.sha);
|
|
159
|
+
} catch { /* */ }
|
|
160
|
+
}
|
|
161
|
+
const gl = gitlabMrApiUrl(originUrl, n);
|
|
162
|
+
if (gl) {
|
|
163
|
+
try {
|
|
164
|
+
const j = httpGetJsonSync(gl);
|
|
165
|
+
if (j?.sha) return String(j.sha);
|
|
166
|
+
} catch { /* */ }
|
|
167
|
+
}
|
|
168
|
+
return '';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function isolatedWorktreesHome(home = homedir()) {
|
|
172
|
+
return path.join(home, '.openzoo', 'grokui-worktrees');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function isGitRepo(dir) {
|
|
176
|
+
if (!dir || !existsSync(dir)) return false;
|
|
177
|
+
return git(['rev-parse', '--is-inside-work-tree'], dir, { allowFail: true }) === 'true';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function mainRepoRoot(dir) {
|
|
181
|
+
if (!isGitRepo(dir)) return null;
|
|
182
|
+
let common = git(['rev-parse', '--path-format=absolute', '--git-common-dir'], dir, { allowFail: true });
|
|
183
|
+
if (!common) {
|
|
184
|
+
const rel = git(['rev-parse', '--git-common-dir'], dir, { allowFail: true });
|
|
185
|
+
common = rel ? path.resolve(dir, rel) : '';
|
|
186
|
+
}
|
|
187
|
+
if (!common) return null;
|
|
188
|
+
if (common.endsWith('.git')) return path.dirname(common);
|
|
189
|
+
return common;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function originUrl(repo) {
|
|
193
|
+
return git(['remote', 'get-url', 'origin'], repo, { allowFail: true });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function freshBaseRef(repo) {
|
|
197
|
+
const sym = git(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], repo, { allowFail: true });
|
|
198
|
+
if (sym) return sym.replace(/^refs\/remotes\//, '');
|
|
199
|
+
for (const cand of ['origin/main', 'origin/master', 'main', 'master']) {
|
|
200
|
+
if (git(['rev-parse', '--verify', cand], repo, { allowFail: true })) return cand;
|
|
201
|
+
}
|
|
202
|
+
return 'HEAD';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function ensureExclude(repo) {
|
|
206
|
+
const common = git(['rev-parse', '--path-format=absolute', '--git-common-dir'], repo, { allowFail: true })
|
|
207
|
+
|| path.join(repo, '.git');
|
|
208
|
+
const exclude = path.join(common, 'info', 'exclude');
|
|
209
|
+
try { mkdirSync(path.dirname(exclude), { recursive: true }); } catch { /* */ }
|
|
210
|
+
let cur = '';
|
|
211
|
+
try { cur = readFileSync(exclude, 'utf8'); } catch { /* */ }
|
|
212
|
+
if (!cur.includes('.openzoo/worktrees')) {
|
|
213
|
+
appendFileSync(exclude, (cur.endsWith('\n') || !cur ? '' : '\n') + '.openzoo/worktrees/\n');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function worktreeListed(repo, dest) {
|
|
218
|
+
const list = git(['worktree', 'list', '--porcelain'], repo, { allowFail: true });
|
|
219
|
+
const want = path.resolve(dest);
|
|
220
|
+
for (const block of list.split('\n\n')) {
|
|
221
|
+
const line = block.split('\n').find((l) => l.startsWith('worktree '));
|
|
222
|
+
if (line && path.resolve(line.slice(9).trim()) === want) return true;
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function simpleGlobMatch(pattern, rel) {
|
|
228
|
+
const p = String(pattern || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
|
229
|
+
const r = String(rel || '').replace(/\\/g, '/');
|
|
230
|
+
if (!p) return false;
|
|
231
|
+
if (!p.includes('*') && !p.includes('?')) {
|
|
232
|
+
return r === p || r.endsWith('/' + p) || path.basename(r) === p;
|
|
233
|
+
}
|
|
234
|
+
const re = new RegExp('^' + p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
|
|
235
|
+
return re.test(r) || re.test(path.basename(r));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Claude .worktreeinclude — copy matching gitignored files. Optional, never blocks. */
|
|
239
|
+
export function copyWorktreeIncludes(repo, dest) {
|
|
240
|
+
const spec = path.join(repo, '.worktreeinclude');
|
|
241
|
+
if (!existsSync(spec) || !existsSync(dest)) return 0;
|
|
242
|
+
const patterns = readFileSync(spec, 'utf8').split(/\r?\n/)
|
|
243
|
+
.map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
|
244
|
+
if (!patterns.length) return 0;
|
|
245
|
+
const ignored = git(['ls-files', '-oi', '--exclude-standard', '-z'], repo, { allowFail: true })
|
|
246
|
+
.split('\0').filter(Boolean);
|
|
247
|
+
let n = 0;
|
|
248
|
+
for (const rel of ignored) {
|
|
249
|
+
if (!patterns.some((p) => simpleGlobMatch(p, rel))) continue;
|
|
250
|
+
const from = path.join(repo, rel);
|
|
251
|
+
const to = path.join(dest, rel);
|
|
252
|
+
try {
|
|
253
|
+
if (!existsSync(from)) continue;
|
|
254
|
+
mkdirSync(path.dirname(to), { recursive: true });
|
|
255
|
+
cpSync(from, to);
|
|
256
|
+
n += 1;
|
|
257
|
+
} catch { /* optional */ }
|
|
258
|
+
}
|
|
259
|
+
return n;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function worktreePath(repo, slug) {
|
|
263
|
+
return path.join(repo, '.openzoo', 'worktrees', slug);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function worktreeBranch(slug) {
|
|
267
|
+
return `worktree-${slug}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function addWorktree(repo, slug, { pr = null } = {}) {
|
|
271
|
+
const dest = worktreePath(repo, slug);
|
|
272
|
+
const branch = worktreeBranch(slug);
|
|
273
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
274
|
+
ensureExclude(repo);
|
|
275
|
+
|
|
276
|
+
if (existsSync(dest) && worktreeListed(repo, dest)) {
|
|
277
|
+
return {
|
|
278
|
+
path: dest, branch, repo, reused: true,
|
|
279
|
+
fetchRef: pr?.n ? fetchSpecsForOrigin(originUrl(repo), pr.n)[0] : '',
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (existsSync(dest)) {
|
|
283
|
+
try { rmSync(dest, { recursive: true, force: true }); } catch { /* */ }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let base = freshBaseRef(repo);
|
|
287
|
+
let fetchRef = '';
|
|
288
|
+
if (pr?.n) {
|
|
289
|
+
const origin = originUrl(repo);
|
|
290
|
+
const specs = fetchSpecsForOrigin(origin, pr.n);
|
|
291
|
+
for (const spec of specs) {
|
|
292
|
+
try {
|
|
293
|
+
git(['fetch', '--no-tags', 'origin', spec], repo, { timeout: FETCH_MS });
|
|
294
|
+
if (git(['rev-parse', '--verify', 'FETCH_HEAD'], repo, { allowFail: true })) {
|
|
295
|
+
fetchRef = spec;
|
|
296
|
+
base = 'FETCH_HEAD';
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
} catch { /* next spec */ }
|
|
300
|
+
}
|
|
301
|
+
if (!fetchRef && origin) {
|
|
302
|
+
const sha = fetchPrHeadShaViaApi(origin, pr.n);
|
|
303
|
+
if (sha) {
|
|
304
|
+
try {
|
|
305
|
+
git(['fetch', '--no-tags', 'origin', sha], repo, { timeout: FETCH_MS });
|
|
306
|
+
if (git(['rev-parse', '--verify', sha], repo, { allowFail: true })) {
|
|
307
|
+
fetchRef = specs[0];
|
|
308
|
+
base = sha;
|
|
309
|
+
}
|
|
310
|
+
} catch { /* isolated fallback */ }
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const hasBranch = Boolean(git(['rev-parse', '--verify', `refs/heads/${branch}`], repo, { allowFail: true }));
|
|
316
|
+
try {
|
|
317
|
+
if (hasBranch) git(['worktree', 'add', dest, branch], repo);
|
|
318
|
+
else git(['worktree', 'add', '-b', branch, dest, base], repo);
|
|
319
|
+
} catch (e) {
|
|
320
|
+
if (!existsSync(dest)) throw e;
|
|
321
|
+
}
|
|
322
|
+
try { copyWorktreeIncludes(repo, dest); } catch { /* optional */ }
|
|
323
|
+
return {
|
|
324
|
+
path: dest, branch, repo, reused: hasBranch, baseRef: base,
|
|
325
|
+
...(fetchRef ? { fetchRef } : {}),
|
|
326
|
+
...(pr?.n ? { pr: pr.n } : {}),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function isolatedChildDir(parentDir, slug) {
|
|
331
|
+
let dest = path.join(isolatedWorktreesHome(), slug);
|
|
332
|
+
const parent = path.resolve(parentDir);
|
|
333
|
+
if (dest === parent || dest.startsWith(parent + path.sep)) {
|
|
334
|
+
dest = path.join(homedir(), '.openzoo', 'grokui-worktrees-out', slug);
|
|
335
|
+
}
|
|
336
|
+
if (dest === parent) throw new Error('refusing to isolate a child into the parent cwd');
|
|
337
|
+
mkdirSync(dest, { recursive: true });
|
|
338
|
+
return { path: dest, branch: null, kind: 'isolated' };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Isolated cwd for a SPAWNed grokui thread (same Node process; shell cwd = t.dir).
|
|
343
|
+
* Git parent → <repo>/.openzoo/worktrees/<slug> on worktree-<slug>.
|
|
344
|
+
* Non-git → ~/.openzoo/grokui-worktrees/<slug>, never the parent dir.
|
|
345
|
+
*/
|
|
346
|
+
export function prepareChildDir(parent, name, spec) {
|
|
347
|
+
const parentDir = path.resolve(parent?.dir || process.env.OZ_WORKSPACE_DIR
|
|
348
|
+
|| path.join(homedir(), '.openzoo', 'grokui-workspace'));
|
|
349
|
+
const pr = extractPrFromSpawn(name, spec);
|
|
350
|
+
const slug = pr && parsePrRef(name) ? `pr-${pr.n}` : agentSlug(name);
|
|
351
|
+
const repo = mainRepoRoot(parentDir);
|
|
352
|
+
if (repo) {
|
|
353
|
+
try {
|
|
354
|
+
const ws = addWorktree(repo, slug, { pr });
|
|
355
|
+
return {
|
|
356
|
+
path: ws.path,
|
|
357
|
+
branch: ws.branch,
|
|
358
|
+
parentDir,
|
|
359
|
+
kind: 'worktree',
|
|
360
|
+
repo,
|
|
361
|
+
fetchRef: ws.fetchRef || '',
|
|
362
|
+
baseRef: ws.baseRef,
|
|
363
|
+
};
|
|
364
|
+
} catch { /* fall back to isolated, still not parent cwd */ }
|
|
365
|
+
}
|
|
366
|
+
return { ...isolatedChildDir(parentDir, slug), parentDir };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function worktreeHasWork(wt) {
|
|
370
|
+
if (!wt?.path || !existsSync(wt.path)) return false;
|
|
371
|
+
if (wt.kind === 'isolated' || !wt.branch) {
|
|
372
|
+
try { return readdirSync(wt.path).length > 0; } catch { return false; }
|
|
373
|
+
}
|
|
374
|
+
if (git(['status', '--porcelain'], wt.path, { allowFail: true })) return true;
|
|
375
|
+
const base = wt.baseRef && wt.baseRef !== 'FETCH_HEAD' && wt.baseRef !== 'HEAD'
|
|
376
|
+
? wt.baseRef
|
|
377
|
+
: (wt.repo ? freshBaseRef(wt.repo) : 'HEAD');
|
|
378
|
+
if (Number(git(['rev-list', '--count', `${base}..HEAD`], wt.path, { allowFail: true })) > 0) return true;
|
|
379
|
+
const unpushed = git(['rev-list', '--count', '@{upstream}..HEAD'], wt.path, { allowFail: true });
|
|
380
|
+
return Boolean(unpushed && Number(unpushed) > 0);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function lockWorktree(wtOrThread) {
|
|
384
|
+
const wt = wtOrThread?.worktree || wtOrThread;
|
|
385
|
+
if (!wt?.path || !wt.branch || !existsSync(wt.path)) return false;
|
|
386
|
+
git(['worktree', 'lock', '--reason', 'openzoo spawn', wt.path], wt.repo || wt.path, { allowFail: true });
|
|
387
|
+
wt.locked = true;
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function unlockWorktree(wtOrThread) {
|
|
392
|
+
const wt = wtOrThread?.worktree || wtOrThread;
|
|
393
|
+
if (!wt?.path || !wt.branch) return false;
|
|
394
|
+
git(['worktree', 'unlock', wt.path], wt.repo || wt.path, { allowFail: true });
|
|
395
|
+
wt.locked = false;
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Clean worktree → remove + delete the branch we created. Dirty → keep. */
|
|
400
|
+
export function finishChildDir(wtOrThread) {
|
|
401
|
+
const thread = wtOrThread && wtOrThread.worktree ? wtOrThread : null;
|
|
402
|
+
const wt = thread?.worktree || wtOrThread;
|
|
403
|
+
if (!wt?.path) return { skipped: true };
|
|
404
|
+
unlockWorktree(wt);
|
|
405
|
+
if (worktreeHasWork(wt)) return { kept: true, path: wt.path, branch: wt.branch };
|
|
406
|
+
if (wt.kind === 'worktree' && wt.repo && wt.branch) {
|
|
407
|
+
git(['worktree', 'remove', '--force', wt.path], wt.repo, { allowFail: true });
|
|
408
|
+
git(['branch', '-D', wt.branch], wt.repo, { allowFail: true });
|
|
409
|
+
}
|
|
410
|
+
try { if (existsSync(wt.path)) rmSync(wt.path, { recursive: true, force: true }); } catch { /* */ }
|
|
411
|
+
if (thread) delete thread.worktree;
|
|
412
|
+
return { removed: true, path: wt.path, branch: wt.branch };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function isWorktreeLocked(repo, dest) {
|
|
416
|
+
const list = git(['worktree', 'list', '--porcelain'], repo, { allowFail: true });
|
|
417
|
+
const want = path.resolve(dest);
|
|
418
|
+
let current = '';
|
|
419
|
+
for (const line of list.split('\n')) {
|
|
420
|
+
if (line.startsWith('worktree ')) current = path.resolve(line.slice(9).trim());
|
|
421
|
+
if ((line === 'locked' || line.startsWith('locked ')) && current === want) return true;
|
|
422
|
+
}
|
|
423
|
+
return false;
|
|
424
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.0",
|
|
4
4
|
"description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
23
23
|
"@solana/spl-token": "^0.4.14",
|
|
24
24
|
"@solana/web3.js": "^1.98.4",
|
|
25
|
+
"dugite": "^3.2.3",
|
|
25
26
|
"selfsigned": "^5.5.0",
|
|
26
27
|
"viem": "^2.21.0",
|
|
27
28
|
"zod": "^3.24.0"
|