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 +197 -89
- package/lib/worktree.mjs +424 -0
- package/package.json +2 -1
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
|
|
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
|
|
@@ -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
|
|
712
|
-
// merely OFFERS
|
|
713
|
-
// model
|
|
714
|
-
//
|
|
715
|
-
// nothing spawned, and a
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
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.
|
|
1335
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
+
|
|
1699
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
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
|
-
&&
|
|
2554
|
-
|
|
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
|
-
//
|
|
2578
|
-
//
|
|
2579
|
-
//
|
|
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.
|
|
2587
|
-
// another continue rather than stopping.
|
|
2693
|
+
// hedge and the user typed continue.
|
|
2588
2694
|
t.autoNudged = true;
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
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
|
-
//
|
|
2603
|
-
//
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
t.
|
|
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,
|
|
5308
|
-
|
|
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
|
};
|
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.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"
|