openzoo 0.48.99 → 0.49.1

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
@@ -12,7 +12,7 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSyn
12
12
  import { cpus, homedir } from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { adaptiveTopK, brain, brainRace, brainStream, tierModels, MODEL, PROXY, TIER_NAMES, normalizeTier } from './podagent.mjs';
15
- import { peekDirectiveStatus, formatRaceStatus, STALE_THINKING_MS, summarizeRaceFailures } from './livestatus.js';
15
+ import { peekDirectiveStatus, formatRaceStatus, STALE_THINKING_MS, summarizeRaceFailures, RACE_EVERY_FAILED } from './livestatus.js';
16
16
  import { creditBalance } from './info.js';
17
17
  import {
18
18
  SUBSCRIPTIONS_PAGE,
@@ -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
@@ -468,13 +491,13 @@ function newThread(name, parent, members) {
468
491
  const p = parent ? threads.get(parent) : null;
469
492
  const t = { id, name, color: members ? members[0].color : colorFor(name), parent: parent || null,
470
493
  messages: members ? null : [{ role: 'system', content: SYSTEM }],
471
- members: members || null, history: [], status: 'idle', createdAt: Date.now(), lastActivityAt: Date.now(),
494
+ members: members || null, history: [], status: 'idle', turnSeq: 0, 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;
@@ -664,6 +687,47 @@ const NUDGE = 'That reply announced work instead of doing it — no directive li
664
687
  const AUTO_CONTINUE = 'AUTO is still on — do not stop and do not ask the user to type continue. '
665
688
  + 'Emit the next directive now (RUN:/SPAWN:/SEND:/READ:/WRITE:/GLOB:/FETCH:/MCP:/SERVE:), '
666
689
  + 'or DONE: if the job is actually finished.';
690
+ const AUTO_RACE_RETRY = 'AUTO is still on — the last model call failed (race/empty/error). '
691
+ + 'Do not stop and do not ask the user to type continue. '
692
+ + 'Emit the next directive now (RUN:/SPAWN:/SEND:/READ:/WRITE:/GLOB:/FETCH:/MCP:/SERVE:), '
693
+ + 'or DONE: if the job is actually finished.';
694
+ // Said it would, without a directive line. "Spawned X" and "working on it" are
695
+ // in here because they are FALSE without a SPAWN: in the same reply — the bot
696
+ // reports success for something the harness never saw.
697
+ const ANNOUNCEMENT = /\b(?:I(?:'| a)?ll |I will |let me |I'm going to |I am going to |first,? |next,? |now I'll |starting|kicking off|spawn(?:ing|ed)|about to|going to (?:check|run|create|start|install))\b/i;
698
+ const STALLED_OFFER = /\b(?:if you want(?:ed)?,? I can|should I\b|let me know(?: and I['’]ll)?|ready to proceed|want me to|would you like(?: me to)?|spawned \S[\s\S]{0,80}working on it|kicked that off)\b/i;
699
+ function isDoneReply(text) {
700
+ return /^[ \t>*-]*DONE:/m.test(String(text || ''));
701
+ }
702
+ function isTransientModelFail(text) {
703
+ const s = String(text || '').trim();
704
+ if (s === RACE_EVERY_FAILED || /^\(race:\s*every model failed/i.test(s)) return true;
705
+ if (/^error:/i.test(s)) return true;
706
+ if (/returned nothing \d+ times|each returned nothing/i.test(s)) return true;
707
+ return false;
708
+ }
709
+ function isPaymentFailed(text) {
710
+ return /\b(?:payment failed|HTTP 402|wallet is empty|empty wallet)\b/i.test(String(text || ''));
711
+ }
712
+ // Park only: ask mode, pendingRun, DONE:, 402/empty-wallet, or the hard cap.
713
+ function shouldKeepAuto(t, reply) {
714
+ if (!t || t.runMode !== 'auto') return false;
715
+ if (t.pendingRun) return false;
716
+ if ((t.autoSteps || 0) >= AUTO_MAX_STEPS) return false;
717
+ if (isDoneReply(reply)) return false;
718
+ if (isPaymentFailed(reply)) return false;
719
+ return true;
720
+ }
721
+ function enqueueAutoHop(t, threadId, userText, onEvent) {
722
+ bindThread(t).catch(() => {});
723
+ t.autoSteps = (t.autoSteps || 0) + 1;
724
+ if (t.autoSteps >= AUTO_MAX_STEPS) return false;
725
+ kickTurn(threadId, userText, onEvent).catch(() => {});
726
+ return true;
727
+ }
728
+ function autoHopText(reply) {
729
+ return isTransientModelFail(reply) ? AUTO_RACE_RETRY : AUTO_CONTINUE;
730
+ }
667
731
  // PING used to be a read: last-line status, no turn. Idle children stayed idle
668
732
  // while the parent treated the dump as evidence they had acted. A ping is a
669
733
  // wake — the same harness continue AUTO already uses, unless a custom message
@@ -672,6 +736,10 @@ let runTurnOverride = null;
672
736
  function setRunTurnForTest(fn) {
673
737
  runTurnOverride = typeof fn === 'function' ? fn : null;
674
738
  }
739
+ let brainAskOverride = null;
740
+ function setBrainAskForTest(fn) {
741
+ brainAskOverride = typeof fn === 'function' ? fn : null;
742
+ }
675
743
  function kickTurn(threadId, userText, onEvent, images) {
676
744
  // Default to emitToThread so a spawned/pinged kid streams when someone has
677
745
  // that thread open. emitToThread is a no-op if nobody is watching.
@@ -708,14 +776,14 @@ const SPAWN_MAX_CHILDREN = Number(process.env.OZ_SPAWN_MAX_CHILDREN)
708
776
 
709
777
  // Injected fresh on every AUTO turn, never persisted into the thread.
710
778
  //
711
- // The auto loop only continues while directives keep parsing, so a reply that
712
- // merely OFFERS ends the run — auto silently degrades to ask the moment the
713
- // model hedges. Observed live: "If you want, I can rewrite the prompt with
714
- // these fixes folded in", "Spawned mcp-integration — working on it" with
715
- // nothing spawned, and a user reduced to answering "no, this... impl all".
716
- // Models are trained to close on a consent question; in auto that instinct is
717
- // the bug. The system prompt is frozen into a thread at creation, so an
718
- // existing thread can only be reached by a per-turn message.
779
+ // The auto loop keeps going until DONE:, a real blocking question, or the
780
+ // step cap. A reply that merely OFFERS used to end the run — auto silently
781
+ // degraded to ask the moment the model hedged. Observed live: "If you want,
782
+ // I can rewrite the prompt", "Spawned mcp-integration — working on it" with
783
+ // nothing spawned, and a kid that RAN ls then sat idle. Models are trained
784
+ // to close on a consent question; in auto that instinct is the bug. The
785
+ // system prompt is frozen into a thread at creation, so an existing thread
786
+ // can only be reached by a per-turn message.
719
787
  const AUTO_DIRECTIVE = `AUTO MODE IS ON for this thread.
720
788
 
721
789
  Do the work in this turn. Do not ask whether to proceed, do not offer to do it,
@@ -733,7 +801,11 @@ Announcing an action does not perform it. "Spawned X", "working on it" and
733
801
  If a task needs several commands, emit the FIRST one now; you get its real
734
802
  output back and continue from there. Only stop to ask when the next step is
735
803
  genuinely destructive and irreversible, or when you truly cannot proceed
736
- without a fact only the user has.`;
804
+ without a fact only the user has.
805
+
806
+ When the job is actually finished, emit DONE: as the first line. A status
807
+ sentence is not a stop — the harness keeps this thread working until DONE:,
808
+ a real blocking question, or the step cap.`;
737
809
  async function bindThread(t) {
738
810
  // Only bind what's NEW since the last successful bind, continuing the
739
811
  // existing context_id — previously this rebuilt and re-sent the WHOLE
@@ -1241,6 +1313,7 @@ async function handleSlash(task, t) {
1241
1313
  const crew = subtreeOf(t.id);
1242
1314
  if (!crew.length) return 'You have no subagents to send to.';
1243
1315
  for (const x of crew) kickTurn(x.id, arg).catch(() => {});
1316
+ if (t.runMode === 'auto' && pingCanWake(t)) wakeOnPing(t, arg);
1244
1317
  return `Sent down your branch to ${crew.length} bot(s): ${crew.map((x) => x.name).join(', ')}`;
1245
1318
  }
1246
1319
 
@@ -1254,6 +1327,10 @@ async function handleSlash(task, t) {
1254
1327
  return crew.map((x) => {
1255
1328
  const mark = x.id === t.id ? ' (here)' : '';
1256
1329
  if (x.id === t.id) {
1330
+ if (t.runMode === 'auto' && pingCanWake(t)) {
1331
+ wakeOnPing(t, arg);
1332
+ return ` ${x.name}${mark}: pinged, working`;
1333
+ }
1257
1334
  const last = x.history[x.history.length - 1];
1258
1335
  return x.pendingRun ? ` ${x.name}${mark}: BLOCKED — waiting for your approval`
1259
1336
  : x.status === 'thinking' ? ` ${x.name}${mark}: working`
@@ -1331,8 +1408,15 @@ setInterval(() => {
1331
1408
  if (!last || now - last < STALE_THINKING_MS) continue;
1332
1409
  t.turnSeq = (t.turnSeq || 0) + 1;
1333
1410
  try { turnAborts.get(t)?.abort(); } catch { /* none */ }
1334
- t.status = 'idle';
1335
- t.liveStatus = '';
1411
+ const lastBot = [...(t.history || [])].reverse().find((h) => h.who === 'bot');
1412
+ const lastText = lastBot?.text || '';
1413
+ if (shouldKeepAuto(t, lastText)) {
1414
+ kickTurn(t.id, isTransientModelFail(lastText) ? AUTO_RACE_RETRY : AUTO_CONTINUE).catch(() => {});
1415
+ } else {
1416
+ t.status = 'idle';
1417
+ t.liveStatus = '';
1418
+ unlockWorktree(t);
1419
+ }
1336
1420
  dirty = true;
1337
1421
  }
1338
1422
  if (dirty) saveThreads();
@@ -1349,7 +1433,7 @@ setInterval(() => {
1349
1433
  const PARALLEL_DIRECTIVE = /^[ \t>*-]*(READ|LS|GLOB|GREP|FETCH|PEEK|MCP):[ \t]*(.+)$/gm;
1350
1434
 
1351
1435
  // 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']);
1436
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '__pycache__', '.venv', 'venv', '.openzoo']);
1353
1437
  function walkDir(base, rel = '', out = [], depth = 0) {
1354
1438
  if (depth > 12 || out.length > 5000) return out;
1355
1439
  let entries = [];
@@ -1591,7 +1675,8 @@ function unwrapDirectiveLine(reply) {
1591
1675
  function isHarnessUserText(text) {
1592
1676
  return /^\((command output|directive result)\)/.test(String(text || ''))
1593
1677
  || String(text || '') === NUDGE
1594
- || String(text || '') === AUTO_CONTINUE;
1678
+ || String(text || '') === AUTO_CONTINUE
1679
+ || String(text || '') === AUTO_RACE_RETRY;
1595
1680
  }
1596
1681
 
1597
1682
  function firstUserAsk(t) {
@@ -1610,7 +1695,7 @@ function siblingJob(sib) {
1610
1695
  return job.trim().replace(/\s+/g, ' ').slice(0, 400);
1611
1696
  }
1612
1697
 
1613
- function spawnBrief(parent, { refresh = false } = {}) {
1698
+ function spawnBrief(parent, { refresh = false, child } = {}) {
1614
1699
  if (!parent) return '';
1615
1700
  const rootId = rootOf(parent).rootId;
1616
1701
  const root = threads.get(rootId) || parent;
@@ -1618,7 +1703,8 @@ function spawnBrief(parent, { refresh = false } = {}) {
1618
1703
  const latest = String(lastUserAsk(parent)?.text || '').trim();
1619
1704
  const recent = (parent.history || []).filter((m) => !isHarnessUserText(m.text)).slice(-8);
1620
1705
  const siblings = [...threads.values()].filter((x) => x.parent === parent.id);
1621
- const cwd = parent.dir || WORKSPACE_DIR;
1706
+ const cwd = child?.dir || WORKSPACE_DIR;
1707
+ const branch = child?.worktree?.branch;
1622
1708
  const mode = parent.runMode || 'ask';
1623
1709
  const tier = parent.tier || 'medium';
1624
1710
  const race = Number(parent.race) || 0;
@@ -1646,6 +1732,7 @@ function spawnBrief(parent, { refresh = false } = {}) {
1646
1732
  }
1647
1733
  lines.push('', 'WORKING SET:');
1648
1734
  lines.push('cwd: ' + cwd);
1735
+ if (branch) lines.push('branch: ' + branch);
1649
1736
  lines.push('run mode: ' + mode);
1650
1737
  lines.push('tier: ' + tier
1651
1738
  + (race >= 2 ? ' · race ' + (raceNeed > 1 ? raceNeed + ' of ' + race : race) : '')
@@ -1662,7 +1749,8 @@ function spawnBrief(parent, { refresh = false } = {}) {
1662
1749
  }
1663
1750
 
1664
1751
  function childKickoff(parent, childName, task, { fresh = true } = {}) {
1665
- return spawnBrief(parent, { refresh: !fresh }) + task + spawnPosition(parent, childName);
1752
+ const child = findByName(childName);
1753
+ return spawnBrief(parent, { refresh: !fresh, child }) + task + spawnPosition(parent, childName);
1666
1754
  }
1667
1755
 
1668
1756
  /**
@@ -1684,6 +1772,7 @@ function spawnPosition(parent, childName) {
1684
1772
  if (!parent) return '';
1685
1773
  const depth = rootOf(parent).depth + 1;
1686
1774
  const others = [...threads.values()].filter((x) => x.parent === parent.id && x.name !== childName);
1775
+ const me = findByName(childName);
1687
1776
  return '\n\n--- your place in the team ---\n'
1688
1777
  + `You are "${childName}", spawned by "${parent.name}". You are at tier ${depth} of this project.\n`
1689
1778
  + (others.length
@@ -1695,8 +1784,12 @@ function spawnPosition(parent, childName) {
1695
1784
  + 'that is how a team becomes sixteen bots and zero artifacts. SPAWN only if your slice '
1696
1785
  + 'contains genuinely independent work that NO existing sibling covers, and name any agent '
1697
1786
  + '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`;
1787
+ + (me?.dir
1788
+ ? `Your working directory is ${me.dir}`
1789
+ + (me.worktree?.branch ? ` on ${me.worktree.branch}` : '')
1790
+ + ' — isolated from the parent checkout. Write here, not in the parent tree.\n'
1791
+ : '')
1792
+ + 'Everything else — build it yourself, now.\n';
1700
1793
  }
1701
1794
 
1702
1795
  /**
@@ -1809,10 +1902,15 @@ async function tryDirective(reply, originId, onEvent) {
1809
1902
  const notes = [];
1810
1903
  for (const { name, task } of parsed) {
1811
1904
  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; }
1905
+ if (existing) {
1906
+ attachChildDir(existing, parent, task);
1907
+ notes.push(`${name} already exists — woke it to keep working.`);
1908
+ made.push({ t: existing, task, fresh: false });
1909
+ continue;
1910
+ }
1813
1911
  const siblings = [...threads.values()].filter((x) => x.parent === originId).length;
1814
1912
  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 });
1913
+ made.push({ t: newThread(name, originId, undefined, task), task, fresh: true });
1816
1914
  }
1817
1915
  // Every thread now exists, so spawnPosition sees the COMPLETE cohort.
1818
1916
  for (const { t: sub, task, fresh } of made) {
@@ -1839,6 +1937,7 @@ async function tryDirective(reply, originId, onEvent) {
1839
1937
  // Repeat SPAWN is a wake, not a CONTEXT REFRESH. childKickoff({fresh:false})
1840
1938
  // restates the original job and tells the child it already exists — MEASURED,
1841
1939
  // the crew flipped to that preview, thought once, and sat.
1940
+ attachChildDir(existing, threads.get(originId), task);
1842
1941
  wakeOnPing(existing);
1843
1942
  return `${name} already exists — woke it to keep working.`;
1844
1943
  }
@@ -1850,7 +1949,7 @@ async function tryDirective(reply, originId, onEvent) {
1850
1949
  + `(limit ${SPAWN_MAX_CHILDREN}). Reuse one with SEND: <name> | <task> — `
1851
1950
  + `every live subagent costs paid calls.`;
1852
1951
  }
1853
- const sub = newThread(name, originId);
1952
+ const sub = newThread(name, originId, undefined, task);
1854
1953
  // The child gets the ORIGINATING brief plus its own job — see spawnBrief.
1855
1954
  kickTurn(sub.id, childKickoff(threads.get(originId), name, task)).catch(() => {}); // fire and forget
1856
1955
  return `Spawned ${name} — working on it.`;
@@ -1892,7 +1991,7 @@ async function tryDirective(reply, originId, onEvent) {
1892
1991
  return `Cannot create "${name}": this thread already has ${siblings} subagents `
1893
1992
  + `(limit ${SPAWN_MAX_CHILDREN}). Reuse one with SEND: <existing name> | <task>.`;
1894
1993
  }
1895
- const sub = newThread(name, originId);
1994
+ const sub = newThread(name, originId, undefined, msg);
1896
1995
  kickTurn(sub.id, childKickoff(threads.get(originId), name, msg)).catch(() => {});
1897
1996
  return `${name} did not exist — spawned it with that message as its task.`;
1898
1997
  }
@@ -1931,6 +2030,15 @@ async function tryDirective(reply, originId, onEvent) {
1931
2030
  wakeOnPing(target);
1932
2031
  return `${name}: pinged, working`;
1933
2032
  }
2033
+ const done = /^[ \t>*-]*DONE:\s*/m.exec(reply);
2034
+ if (done) {
2035
+ const t = threads.get(originId);
2036
+ if (!t) return 'Done.';
2037
+ const result = finishChildDir(t);
2038
+ if (result.removed) return 'Done — clean worktree removed.';
2039
+ if (result.kept) return 'Done — worktree kept (has local work).';
2040
+ return 'Done.';
2041
+ }
1934
2042
  const peek = /^[ \t>*-]*PEEK:\s*(.+)/m.exec(reply);
1935
2043
  if (peek) {
1936
2044
  const name = peek[1].trim();
@@ -2318,7 +2426,11 @@ async function runTurn(threadId, userText, onEvent, images) {
2318
2426
  // status=thinking and the UI on "…".
2319
2427
  if (!isHarnessUserText(userText)) t.turnSeq = (t.turnSeq || 0) + 1;
2320
2428
  const seq = t.turnSeq || 0;
2321
- const stillMine = () => threads.get(threadId)?.turnSeq === seq;
2429
+ // Missing turnSeq is 0, not undefined. A ping/AUTO_CONTINUE as the first
2430
+ // message used to make stillMine() always false (undefined === 0), so the
2431
+ // RUN ran or the model was paid and then the hop bailed before chaining —
2432
+ // kids looked "pinged" then dead.
2433
+ const stillMine = () => (threads.get(threadId)?.turnSeq || 0) === seq;
2322
2434
  const paint = (ev) => {
2323
2435
  if (!stillMine()) return;
2324
2436
  if (ev.type === 'status' && ev.detail && t.status === 'thinking') t.liveStatus = ev.detail;
@@ -2333,16 +2445,19 @@ async function runTurn(threadId, userText, onEvent, images) {
2333
2445
  try { turnAborts.get(t)?.abort(); } catch { /* none */ }
2334
2446
  const turnAbort = new AbortController();
2335
2447
  turnAborts.set(t, turnAbort);
2448
+ lockWorktree(t);
2336
2449
  const raceN = Math.min(Number(t.race) || 0, 4);
2337
2450
  const raceNeed = Math.min(Math.max(Number(t.raceNeed) || 1, 1), raceN || 1);
2338
2451
  t.liveStatus = (!t.model && raceN >= 2) ? formatRaceStatus(0, raceNeed) : 'waiting on model…';
2339
2452
  let chained = false;
2340
2453
  let parked = false;
2454
+ let lastReply = '';
2341
2455
  try {
2342
2456
  if (t.members) {
2343
2457
  // sequential, not parallel: each member's context is rebuilt from
2344
2458
  // t.history right before its turn, so it sees every reply (including
2345
2459
  // spawns/sends) the earlier members in THIS round already made
2460
+ let memberReply = '';
2346
2461
  for (const m of t.members) {
2347
2462
  if (!stillMine()) return;
2348
2463
  const msgs = buildMemberMessages(t, m);
@@ -2356,6 +2471,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2356
2471
  } catch (e) { r = `error: ${e.message}`; }
2357
2472
  if (!stillMine()) return;
2358
2473
  r = stripThinkTags(r);
2474
+ memberReply = r;
2359
2475
  const runCmd = parseRun(r);
2360
2476
  if (runCmd) {
2361
2477
  const command = runCmd;
@@ -2365,6 +2481,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2365
2481
  const shown = `$ ${command}\n${output}`;
2366
2482
  t.history.push({ who: 'bot', text: shown, name: m.name, color: m.color });
2367
2483
  paint({ type: 'final', name: m.name, color: m.color, text: shown });
2484
+ memberReply = shown;
2368
2485
  // this member's turn is done; the round continues to the next member
2369
2486
  continue;
2370
2487
  }
@@ -2381,8 +2498,13 @@ async function runTurn(threadId, userText, onEvent, images) {
2381
2498
  const finalText = ack ?? (r || '(no response)');
2382
2499
  t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color });
2383
2500
  paint({ type: 'final', name: m.name, color: m.color, text: finalText });
2501
+ memberReply = r;
2384
2502
  }
2385
2503
  bindThread(t).catch(() => {});
2504
+ lastReply = memberReply;
2505
+ if (shouldKeepAuto(t, memberReply)) {
2506
+ chained = enqueueAutoHop(t, threadId, autoHopText(memberReply), onEvent);
2507
+ }
2386
2508
  return;
2387
2509
  }
2388
2510
  // A real message from the user resets the auto budget AND the announcement
@@ -2421,6 +2543,11 @@ async function runTurn(threadId, userText, onEvent, images) {
2421
2543
  // `attempt` exists because a retry must be allowed to land somewhere else:
2422
2544
  // see the empty-completion loop below.
2423
2545
  const ask = async (attempt = 0) => {
2546
+ if (brainAskOverride) {
2547
+ return String(await brainAskOverride({
2548
+ thread: t, attempt, userText, messages: callMsgs,
2549
+ }) ?? '').trim();
2550
+ }
2424
2551
  const emit = (delta, meta) => paint({
2425
2552
  type: 'delta', name: t.name, color: t.color, delta,
2426
2553
  ...(meta?.replace ? { replace: true } : {}),
@@ -2468,7 +2595,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2468
2595
  // sick provider. Empties are per-model and uncorrelated, so moving is the
2469
2596
  // fix. A thread pinned with /model stays pinned; that was an explicit
2470
2597
  // choice and silently answering as something else would be worse.
2471
- for (let i = 0; !reply && i < AUTO_EMPTY_RETRIES; i++) {
2598
+ for (let i = 0; (!reply || isTransientModelFail(reply)) && i < AUTO_EMPTY_RETRIES; i++) {
2472
2599
  paint({ type: 'status', name: t.name, color: t.color, detail: 'retrying…' });
2473
2600
  await new Promise((r) => setTimeout(r, 400 * (i + 1)));
2474
2601
  if (!stillMine()) return;
@@ -2484,6 +2611,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2484
2611
  }
2485
2612
  if (!stillMine()) return;
2486
2613
  reply = stripThinkTags(reply);
2614
+ lastReply = reply;
2487
2615
  t.messages.push({ role: 'assistant', content: reply });
2488
2616
  const runCmd = parseRun(reply);
2489
2617
  if (runCmd) {
@@ -2503,24 +2631,13 @@ async function runTurn(threadId, userText, onEvent, images) {
2503
2631
  // Bounded, because this is a loop that spends real money on every hop:
2504
2632
  // AUTO_MAX_STEPS chained commands per user message, reset whenever the
2505
2633
  // user speaks again.
2506
- t.autoSteps = (t.autoSteps || 0) + 1;
2507
- if (t.autoSteps < AUTO_MAX_STEPS) {
2508
- // BIND BEFORE CHAINING. bindThread only ran at the end of a normal
2509
- // turn, and both auto paths return before reaching it — so in auto
2510
- // mode nothing was ever bound, exactly when the agent produces the
2511
- // most material (command output, GLOB results, MCP tool lists). The
2512
- // holographic context stopped growing precisely when it mattered.
2513
- bindThread(t).catch(() => {});
2514
- chained = true;
2515
- runTurn(threadId, condense('(command output)', output), onEvent).catch(() => {});
2516
- } else {
2517
- // Do not park. A "say continue" note is the failure mode auto exists
2518
- // to avoid — inject continue and keep going until DONE or ask.
2519
- t.autoSteps = 0;
2520
- bindThread(t).catch(() => {});
2521
- chained = true;
2522
- runTurn(threadId, AUTO_CONTINUE, onEvent).catch(() => {});
2523
- }
2634
+ // BIND BEFORE CHAINING. bindThread only ran at the end of a normal
2635
+ // turn, and both auto paths return before reaching it — so in auto
2636
+ // mode nothing was ever bound, exactly when the agent produces the
2637
+ // most material (command output, GLOB results, MCP tool lists). The
2638
+ // holographic context stopped growing precisely when it mattered.
2639
+ lastReply = shown;
2640
+ chained = enqueueAutoHop(t, threadId, condense('(command output)', output), onEvent);
2524
2641
  return;
2525
2642
  }
2526
2643
  {
@@ -2550,16 +2667,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2550
2667
  // Same budget as RUN (shared t.autoSteps, reset when the user speaks), so
2551
2668
  // this cannot spend more than a chained RUN loop already could.
2552
2669
  if (t.runMode === 'auto' && ack !== null && ack !== undefined
2553
- && !/^[ \t>*-]*DONE:/m.test(reply)) {
2554
- t.autoSteps = (t.autoSteps || 0) + 1;
2555
- bindThread(t).catch(() => {}); // bind every hop, not just the last one
2556
- chained = true;
2557
- if (t.autoSteps < AUTO_MAX_STEPS) {
2558
- runTurn(threadId, condense('(directive result)', ack), onEvent).catch(() => {});
2559
- } else {
2560
- t.autoSteps = 0;
2561
- runTurn(threadId, AUTO_CONTINUE, onEvent).catch(() => {});
2562
- }
2670
+ && !isDoneReply(reply)) {
2671
+ chained = enqueueAutoHop(t, threadId, condense('(directive result)', ack), onEvent);
2563
2672
  return;
2564
2673
  }
2565
2674
 
@@ -2574,49 +2683,43 @@ async function runTurn(threadId, userText, onEvent, images) {
2574
2683
  // AUTO_DIRECTIVE already forbids this in the prompt, and models do it anyway.
2575
2684
  // A prompt rule with no enforcement is a suggestion. Re-ask once, in-band.
2576
2685
  //
2577
- // NARROW ON PURPOSE. It fires only on announcement language, never on a plain
2578
- // answer auto is also how you hold an ordinary conversation, and nudging a
2579
- // finished reply would spend a turn arguing with a bot that already
2580
- // succeeded. Once per turn (autoNudged), so a model that announces twice
2581
- // still stops instead of looping on the user's wallet.
2686
+ // NUDGE announcements (stronger than a bare continue). Plain replies used
2687
+ // to park here; they now fall through to AUTO_CONTINUE unless DONE: or a
2688
+ // real blocking question. The step cap is the wallet bound.
2582
2689
  if (t.runMode === 'auto' && (ack === null || ack === undefined)
2583
2690
  && (STALLED_OFFER.test(reply) || ANNOUNCEMENT.test(reply))) {
2584
2691
  // Offers and "Spawned X — working on it" with no directive must not end
2585
2692
  // the run. The old once-only autoNudged gate parked the thread after one
2586
- // hedge and the user typed continue. Keep going; AUTO_MAX_STEPS injects
2587
- // another continue rather than stopping.
2693
+ // hedge and the user typed continue.
2588
2694
  t.autoNudged = true;
2589
- t.autoSteps = (t.autoSteps || 0) + 1;
2590
- bindThread(t).catch(() => {});
2591
- chained = true;
2592
- if (t.autoSteps < AUTO_MAX_STEPS) {
2593
- runTurn(threadId, NUDGE, onEvent).catch(() => {});
2594
- } else {
2595
- t.autoSteps = 0;
2596
- runTurn(threadId, AUTO_CONTINUE, onEvent).catch(() => {});
2597
- }
2695
+ chained = enqueueAutoHop(t, threadId, NUDGE, onEvent);
2696
+ return;
2697
+ }
2698
+
2699
+ // After any auto reply that is not DONE: and not waiting on approval,
2700
+ // kick immediately. Race/empty/error uses AUTO_RACE_RETRY.
2701
+ if (shouldKeepAuto(t, reply)) {
2702
+ chained = enqueueAutoHop(t, threadId, autoHopText(reply), onEvent);
2598
2703
  return;
2599
2704
  }
2600
2705
  bindThread(t).catch(() => {});
2601
2706
  } finally {
2602
- // A hung or thrown brainStream used to leave t.status = 'thinking' and the
2603
- // UI on mute "…" forever. Always idle unless this hop chained or parked
2604
- // on an approval the next runTurn sets thinking again itself.
2605
- if (stillMine() && !chained && !parked && !t.pendingRun) {
2606
- t.status = 'idle';
2607
- t.liveStatus = '';
2707
+ // Idle only when this hop should not keep AUTO going: DONE:, pendingRun,
2708
+ // ask mode, 402/empty-wallet, or the hard cap. Otherwise kick again.
2709
+ if (stillMine() && !chained && !parked) {
2710
+ if (shouldKeepAuto(t, lastReply)) {
2711
+ enqueueAutoHop(t, threadId, autoHopText(lastReply), onEvent);
2712
+ } else if (!t.pendingRun) {
2713
+ t.status = 'idle';
2714
+ t.liveStatus = '';
2715
+ unlockWorktree(t);
2716
+ }
2608
2717
  }
2609
2718
  t.lastActivityAt = Date.now();
2610
2719
  saveThreads();
2611
2720
  }
2612
2721
  }
2613
2722
 
2614
- // Said it would, without a directive line. "Spawned X" and "working on it" are
2615
- // in here because they are FALSE without a SPAWN: in the same reply — the bot
2616
- // reports success for something the harness never saw.
2617
- const ANNOUNCEMENT = /\b(?:I(?:'| a)?ll |I will |let me |I'm going to |I am going to |first,? |next,? |now I'll |starting|kicking off|spawn(?:ing|ed)|about to|going to (?:check|run|create|start|install))\b/i;
2618
- const STALLED_OFFER = /\b(?:if you want(?:ed)?,? I can|should I\b|let me know(?: and I['’]ll)?|ready to proceed|want me to|would you like(?: me to)?|spawned \S[\s\S]{0,80}working on it|kicked that off)\b/i;
2619
-
2620
2723
  /**
2621
2724
  * The PROJECT a thread belongs to = the root of its spawn tree, and how deep
2622
2725
  * it sits. Every thread already carried the parent id; nothing ever walked it, so
@@ -5171,6 +5274,8 @@ const server = http.createServer((req, res) => {
5171
5274
  return;
5172
5275
  }
5173
5276
  if (req.method === 'DELETE' && req.url.startsWith('/threads/')) {
5277
+ const doomed = threads.get(req.url.split('/')[2]);
5278
+ if (doomed) finishChildDir(doomed);
5174
5279
  threads.delete(req.url.split('/')[2]);
5175
5280
  saveThreads();
5176
5281
  res.writeHead(200, { 'content-type': 'application/json' });
@@ -5304,6 +5409,9 @@ server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0
5304
5409
  export {
5305
5410
  tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
5306
5411
  parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
5307
- handleSlash, newThread, setRunTurnForTest, AUTO_CONTINUE, pingWakeText, pingCanWake,
5308
- childKickoff,
5412
+ handleSlash, newThread, setRunTurnForTest, setBrainAskForTest, runTurn,
5413
+ AUTO_CONTINUE, AUTO_RACE_RETRY, pingWakeText, pingCanWake, shouldKeepAuto,
5414
+ isDoneReply, isTransientModelFail, enqueueAutoHop, childKickoff, findByName,
5415
+ attachChildDir, finishChildDir,
5416
+ lockWorktree, unlockWorktree, parsePrRef, fetchSpecsForOrigin, agentSlug,
5309
5417
  };
@@ -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.1",
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"