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 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 newThread(name, parent, members) {
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, working directory and model.
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 matters just as much — a child that defaults elsewhere cannot see the
460
- // files the parent was sent to work on.
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 = parent.dir || WORKSPACE_DIR;
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
- return spawnBrief(parent, { refresh: !fresh }) + task + spawnPosition(parent, childName);
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
- + 'Work in your own directory so you cannot collide with a sibling: '
1699
- + `type /dir <path> if you need one. Everything else — build it yourself, now.\n`;
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) { notes.push(`${name} already exists — woke it to keep working.`); made.push({ t: existing, task, fresh: false }); continue; }
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
  };
@@ -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.48.99",
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"