symbiot 0.57.3 → 0.57.6
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/CHANGELOG.md +26 -0
- package/README.md +41 -9
- package/agents.mjs +116 -8
- package/handover.mjs +86 -4
- package/home.mjs +226 -25
- package/index.mjs +7 -11
- package/lanes.mjs +49 -6
- package/marketing.mjs +110 -8
- package/mind.mjs +7 -3
- package/package.json +2 -2
- package/post.mjs +41 -17
- package/reports.mjs +102 -20
- package/server.mjs +39 -14
- package/tasks.mjs +96 -29
- package/ui.mjs +141 -54
- package/watch.mjs +110 -11
package/server.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { EMBEDDED_UI } from "./ui.mjs";
|
|
|
16
16
|
import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary, CONFIG_DIR } from "./core.mjs";
|
|
17
17
|
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, linkReach, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull, readLastWords, sandboxState } from "./agents.mjs";
|
|
18
18
|
import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
|
|
19
|
-
import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap, laneMap, setScanOptions, refreshRepos, REPO_STATE } from "./scan.mjs";
|
|
19
|
+
import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap, laneMap, setScanOptions, refreshRepos, REPO_STATE, reposState } from "./scan.mjs";
|
|
20
20
|
import { computeDrift } from "./drift.mjs";
|
|
21
21
|
import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, releaseInput, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
|
|
22
22
|
import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce, releaseNotes } from "./writeups.mjs";
|
|
@@ -30,9 +30,9 @@ import { mindState, forget } from "./mind.mjs";
|
|
|
30
30
|
import { lanesTick, lanesState, partlyDone, orcaRelink } from "./lanes.mjs";
|
|
31
31
|
import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
|
|
32
32
|
import { adaptState, noteUse } from "./adapt.mjs";
|
|
33
|
-
import { homeState, homeAsk, homeAnswer, homeNext, workScene, workGo, firstSteps, marketingState, marketingGo, marketingTask, moveToMarketing, onboarding, setOnboarding } from "./home.mjs";
|
|
34
|
-
import { MARKETING_DIR } from "./marketing.mjs";
|
|
35
|
-
import { listReports, readReport, markAllRead } from "./reports.mjs";
|
|
33
|
+
import { reportIdeasAdd, reportAsk, reportDraftAnswer, homeState, homeAsk, homeAnswer, homeNext, workScene, workGo, workTick, firstSteps, marketingState, marketingGo, marketingDraftAnswer, marketingTask, moveToMarketing, onboarding, setOnboarding } from "./home.mjs";
|
|
34
|
+
import { MARKETING_DIR, MARKETING, draftPreview, laneMedia, displayName } from "./marketing.mjs";
|
|
35
|
+
import { listReports, readReport, reportImage, markAllRead } from "./reports.mjs";
|
|
36
36
|
import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
|
|
37
37
|
import { knowledgeState, addKnowledgeFolder, removeKnowledgeFolder, indexKnowledge, knowledgeTick, searchKnowledge } from "./knowledge.mjs";
|
|
38
38
|
import { runChecks, checksState, markClashesSeen } from "./checks.mjs";
|
|
@@ -52,6 +52,14 @@ function updateCmd(latest, current, platform = process.platform) {
|
|
|
52
52
|
// update the app shows once what came with it (config.seenVersion is the version
|
|
53
53
|
// you last saw it for). Before an update, the new version's own CHANGELOG.md is
|
|
54
54
|
// read from its package on npm (registryChangelog).
|
|
55
|
+
|
|
56
|
+
// A folder's lane as people say it: the repo (or Marketing) it is, else "" (an ops run).
|
|
57
|
+
function laneOfFolder(path) {
|
|
58
|
+
if (path === MARKETING_DIR) return "Marketing";
|
|
59
|
+
let map = {}; try { map = laneMap() || {}; } catch {}
|
|
60
|
+
const n = Object.keys(map).find((k) => map[k] === path);
|
|
61
|
+
return n ? (n === MARKETING ? "Marketing" : displayName(path, n)) : "";
|
|
62
|
+
}
|
|
55
63
|
const CHANGELOG = fileURLToPath(new URL("./CHANGELOG.md", import.meta.url));
|
|
56
64
|
function changesSince(md, from, to) {
|
|
57
65
|
const out = [];
|
|
@@ -62,6 +70,8 @@ function changesSince(md, from, to) {
|
|
|
62
70
|
}
|
|
63
71
|
return out.slice(0, 15);
|
|
64
72
|
}
|
|
73
|
+
// A headless browser: an agent or a test looking at the page, not someone using it.
|
|
74
|
+
const headlessAgent = (ua) => /Headless/i.test(String(ua || ""));
|
|
65
75
|
const localChangelog = () => { try { return readFileSync(CHANGELOG, "utf8"); } catch { return ""; } };
|
|
66
76
|
// After an update: what's new since the version you last saw it for. The first
|
|
67
77
|
// time (no seenVersion yet) that's just this version's own release.
|
|
@@ -273,12 +283,12 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
273
283
|
await learnNpm(ask); return json(res, partly(pendingReview()));
|
|
274
284
|
}
|
|
275
285
|
if (u.pathname === "/api/pending/diff") { const p = laneMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(p) : "" }); }
|
|
276
|
-
if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req), repo = String(b.repo || ""); if (b.bump) await learnNpm([laneMap()[repo]]); const notes = await releaseNotes(releaseInput(repo, { bump: b.bump })); return json(res, approveRepo(repo, { bump: b.bump, notes })); }
|
|
277
|
-
if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req), repo = String(b.repo || ""); if (b.bump) await learnNpm([laneMap()[repo]]); const notes = await releaseNotes(releaseInput(repo, { bump: b.bump, tick: b.tick })); return json(res, approveChanges(repo, { bump: b.bump, tick: b.tick, notes })); }
|
|
286
|
+
if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req), repo = String(b.repo || ""); if (b.bump) await learnNpm([laneMap()[repo]]); const notes = await releaseNotes(releaseInput(repo, { bump: b.bump })); return json(res, await approveRepo(repo, { bump: b.bump, notes })); }
|
|
287
|
+
if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req), repo = String(b.repo || ""); if (b.bump) await learnNpm([laneMap()[repo]]); const notes = await releaseNotes(releaseInput(repo, { bump: b.bump, tick: b.tick })); return json(res, await approveChanges(repo, { bump: b.bump, tick: b.tick, notes })); }
|
|
278
288
|
if (u.pathname === "/api/pending/sendback" && req.method === "POST") { const b = await readBody(req); return json(res, sendBack(String(b.id || ""))); }
|
|
279
289
|
if (u.pathname === "/api/automerge" && req.method === "POST") { const b = await readBody(req); return json(res, setAutoMerge(String(b.repo || ""), !!b.on)); }
|
|
280
290
|
if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
|
|
281
|
-
if (u.pathname === "/api/scanroots") return json(res, { roots: loadConfig().scanRoots || [], effective: scanRoots(), home: scanHome() });
|
|
291
|
+
if (u.pathname === "/api/scanroots") { const st = reposState(); return json(res, { roots: loadConfig().scanRoots || [], effective: scanRoots(), home: scanHome(), searching: st.searching, searched: st.at || 0, found: st.list.length }); }
|
|
282
292
|
if (u.pathname === "/api/scanroots/add" && req.method === "POST") { const b = await readBody(req); return json(res, addScanRoot(String(b.path || ""))); }
|
|
283
293
|
if (u.pathname === "/api/scanroots/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeScanRoot(String(b.path || ""))); }
|
|
284
294
|
// knowledge folders (knowledge.mjs): added or removed, the index catches up at once
|
|
@@ -307,7 +317,9 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
307
317
|
return json(res, { started: true, model, id: e ? e.id : "" });
|
|
308
318
|
}
|
|
309
319
|
if (u.pathname === "/api/agents") return json(res, await withReleases(agentsList()));
|
|
310
|
-
|
|
320
|
+
// the answer, saved in the folder that asked; it says which lane that is, so "Sent to …"
|
|
321
|
+
// names where it went (a Marketing answer said "Sent to Paperclip Steve")
|
|
322
|
+
if (u.pathname === "/api/agents/answer" && req.method === "POST") { const b = await readBody(req), path = String(b.path || ""), r = answerQuestions(path, b.answers, { rerun: !!b.rerun }); return json(res, r && r.ok ? { ...r, lane: laneOfFolder(path) } : r); }
|
|
311
323
|
if (u.pathname === "/api/agents/skip" && req.method === "POST") { const b = await readBody(req); return json(res, skipIdea(String(b.path || ""), b.text)); }
|
|
312
324
|
// What a run handed back (handback.mjs): facts for memory, kept only on Remember; replies it waits on.
|
|
313
325
|
if (u.pathname === "/api/agents/remember" && req.method === "POST") { const b = await readBody(req); return json(res, b.skip === true ? skipFacts(String(b.path || "")) : keepFacts(String(b.path || ""), { only: Array.isArray(b.only) ? b.only.map(Number) : undefined })); }
|
|
@@ -356,8 +368,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
356
368
|
if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""))); }
|
|
357
369
|
if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
|
|
358
370
|
if (u.pathname === "/api/links/unlink" && req.method === "POST") { const b = await readBody(req); return json(res, unlinkSite(String(b.id || ""))); }
|
|
359
|
-
// Posts (post.mjs): the week's drafts, waiting on you. Approve
|
|
360
|
-
//
|
|
371
|
+
// Posts (post.mjs): the week's drafts, waiting on you. Approve is your yes: Marketing's
|
|
372
|
+
// agent posts it through Symbiot's signed-in browser.
|
|
361
373
|
if (u.pathname === "/api/posts") return json(res, postsState());
|
|
362
374
|
// Marketing: replies on LinkedIn (its watched notifications, marked "maybe a customer"),
|
|
363
375
|
// and the 4-week test's table, with npm installs of the packages the posts are about
|
|
@@ -373,9 +385,14 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
373
385
|
if (u.pathname === "/api/marketing/task" && req.method === "POST") { const b = await readBody(req); return json(res, marketingTask(b.text, b.product)); }
|
|
374
386
|
if (u.pathname === "/api/marketing/move" && req.method === "POST") { const b = await readBody(req); return json(res, moveToMarketing(b.id, b.product)); }
|
|
375
387
|
if (u.pathname === "/api/marketing/go" && req.method === "POST") return json(res, marketingGo());
|
|
388
|
+
// a draft as the post it will be (its preview, notes apart), its picture or video, and
|
|
389
|
+
// your Approve or Skip on it: approved, its agent posts it through Symbiot's browser
|
|
390
|
+
if (u.pathname === "/api/marketing/draft") return json(res, draftPreview(String(u.searchParams.get("rel") || "")));
|
|
391
|
+
if (u.pathname === "/api/marketing/media") { const m = laneMedia(String(u.searchParams.get("rel") || "")); if (!m) { res.writeHead(404); res.end("not found"); return; } res.writeHead(200, { "content-type": m.type, "cache-control": "private, max-age=300" }); res.end(readFileSync(m.file)); return; }
|
|
392
|
+
if (u.pathname === "/api/marketing/draft/answer" && req.method === "POST") { const b = await readBody(req); return json(res, marketingDraftAnswer(String(b.rel || ""), b.skip ? "skipped" : "approved")); }
|
|
376
393
|
if (u.pathname === "/api/marketing/open" && req.method === "POST") { const b = await readBody(req), f = join(MARKETING_DIR, String(b.rel || "")); return json(res, f.startsWith(MARKETING_DIR + "/") && existsSync(f) ? { ok: openUrl(f) } : { error: "That draft isn't there any more." }); }
|
|
377
394
|
if (u.pathname === "/api/posts/draft" && req.method === "POST") return json(res, await draftPosts());
|
|
378
|
-
if (u.pathname === "/api/posts/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approvePost(String(b.id || "")
|
|
395
|
+
if (u.pathname === "/api/posts/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approvePost(String(b.id || ""))); }
|
|
379
396
|
if (u.pathname === "/api/posts/edit" && req.method === "POST") { const b = await readBody(req); return json(res, editPost(String(b.id || ""), b.text)); }
|
|
380
397
|
if (u.pathname === "/api/posts/skip" && req.method === "POST") { const b = await readBody(req); return json(res, skipPost(String(b.id || ""))); }
|
|
381
398
|
// a draft's pictures and video: yours (the file itself as the body), a picture or clip of a page, shown, removed, their folder opened
|
|
@@ -389,7 +406,12 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
389
406
|
// Reports (reports.mjs): what runs wrote up in their .symbiot/; read by id, never by path.
|
|
390
407
|
if (u.pathname === "/api/reports") return json(res, { reports: listReports() });
|
|
391
408
|
if (u.pathname === "/api/reports/read") return json(res, readReport(u.searchParams.get("id") || ""));
|
|
409
|
+
// A report's image (an <img> src, so its token is in ?t=): by the report's id and a src it shows, from its allowed folders only.
|
|
410
|
+
if (u.pathname === "/api/reports/image") { const f = reportImage(u.searchParams.get("id") || "", u.searchParams.get("src") || ""); if (f.error) { res.writeHead(404, { "content-type": "text/plain" }); res.end(f.error); return; } res.writeHead(200, { "content-type": f.type, "cache-control": "private, no-cache", "x-content-type-options": "nosniff" }); res.end(readFileSync(f.file)); return; }
|
|
392
411
|
if (u.pathname === "/api/reports/seen" && req.method === "POST") return json(res, markAllRead());
|
|
412
|
+
if (u.pathname === "/api/reports/ideas" && req.method === "POST") { const b = await readBody(req); return json(res, reportIdeasAdd(String(b.id || ""), b.ideas)); }
|
|
413
|
+
if (u.pathname === "/api/reports/ask" && req.method === "POST") { const b = await readBody(req); return json(res, await reportAsk(String(b.id || ""), b.question)); }
|
|
414
|
+
if (u.pathname === "/api/reports/draft" && req.method === "POST") { const b = await readBody(req); return json(res, reportDraftAnswer(String(b.id || ""), !!b.approve)); }
|
|
393
415
|
// Home (home.mjs): the liquid's live data, and its talk; Adapt (adapt.mjs): its
|
|
394
416
|
// shape from how you use it (commit=1 when it wakes from rest, never mid-gesture).
|
|
395
417
|
if (u.pathname === "/api/home") return json(res, homeState({ fresh: u.searchParams.get("fresh") === "1" }));
|
|
@@ -448,8 +470,10 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
448
470
|
// the Week tab's week is saved to weeks/ too, as Write it now saves it (no notification: you're looking at it)
|
|
449
471
|
if (u.pathname === "/api/run" && req.method === "POST") { const b = await readBody(req); const cmd = ["week", "standup", "todo"].includes(b.cmd) ? b.cmd : "week"; return json(res, cmd === "week" ? await runWeekly(writeup, { notify: false }) : await writeup(cmd)); }
|
|
450
472
|
if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
|
|
451
|
-
// a sandbox (symbiot app --fresh) never offers an update: it would replace your real install
|
|
452
|
-
|
|
473
|
+
// a sandbox (symbiot app --fresh) never offers an update: it would replace your real install.
|
|
474
|
+
// A headless browser (an agent's screenshot of Home) never becomes the newest window:
|
|
475
|
+
// that closed the user's real one, again and again (2026-10-08, "symbiot keeps crashing").
|
|
476
|
+
if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); const w = u.searchParams.get("w") || ""; if (/^[a-z0-9]{6,20}$/.test(w) && u.searchParams.get("new") === "1" && !headlessAgent(req.headers["user-agent"])) NEWEST_WIN = w; return json(res, { ...(NEWEST_WIN ? { window: NEWEST_WIN } : {}), version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && semverGt(LATEST_VERSION, VERSION), ...(UPDATING && UPDATING.retrying ? { retrying: UPDATING } : {}), ...(IN_TERMUX ? { termux: true } : {}), ...(SANDBOX ? { sandbox: true } : {}) }); }
|
|
453
477
|
// What's new: after an update, since the version you last saw (until you click Got it);
|
|
454
478
|
// ?latest=1, what the update on offer brings, from its package on npm
|
|
455
479
|
if (u.pathname === "/api/whatsnew") {
|
|
@@ -516,6 +540,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
516
540
|
startWatches(); // pages you watch (Screens → Watch), read every few minutes
|
|
517
541
|
startPhone(); // Watch on your phone: the computer listens if it's switched on, the phone asks if it's paired
|
|
518
542
|
setInterval(() => { try { startWaiting(); } catch {} }, 20000).unref(); // a run that waits for your step starts once the file it names changes
|
|
543
|
+
setInterval(() => { try { workTick(); } catch {} }, 20000).unref(); // a waiting task starts once its lane is free (config.autoStart: false leaves it to Go)
|
|
519
544
|
setInterval(() => { try { lanesTick(); } catch {} }, 20000).unref(); // agents hand work to other lanes, and hear back when it's done
|
|
520
545
|
setTimeout(() => { orcaRelink().catch(() => {}); }, 15000).unref(); // a lane folder a handover renamed, added again in Orca where it is now
|
|
521
546
|
// an agent's allow list that stays inside your work is turned on by itself, and the agent carries on
|
|
@@ -529,4 +554,4 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
529
554
|
setInterval(() => { try { awaitTick(); } catch {} }, 60000).unref(); // a reply an agent's email waits on: found, and handed on
|
|
530
555
|
}
|
|
531
556
|
|
|
532
|
-
export { updateCmd, changesSince, whatsNew, tarFile, registryChangelog, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly, openApp };
|
|
557
|
+
export { headlessAgent, updateCmd, changesSince, whatsNew, tarFile, registryChangelog, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly, openApp };
|
package/tasks.mjs
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
// each repo gets (.symbiot/TASKS.md), what the agent ticked (review), and
|
|
3
3
|
// Approve: branch, commit, push, PR, and the version bump and npm release facts
|
|
4
4
|
// it shows.
|
|
5
|
-
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, statSync } from "node:fs";
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
9
|
import { VERSION, LATEST_VERSION, REGISTRY, semverGt, loadConfig, saveConfig, loadTasks, saveTasks, TASK_MAX, clipWords, taskWords, sameTask, uniqueTasks, sh, hasCmd, repoState } from "./core.mjs";
|
|
10
10
|
import { handoverRules, ONLY_YOU, HANDBACK } from "./handover.mjs";
|
|
11
11
|
import { userStyleLine } from "./adapt.mjs";
|
|
12
|
-
import { QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, handoffCmd, runningHandoff, writeTasks, droppedTasks, startHeldTasks, connectorsLine } from "./agents.mjs";
|
|
12
|
+
import { QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, isUrgent, handoffCmd, runningHandoff, untrackedBefore, forgetUntracked, writeTasks, droppedTasks, startHeldTasks, connectorsLine } from "./agents.mjs";
|
|
13
13
|
import { gitDefaultBranch } from "./drift.mjs";
|
|
14
14
|
import { readRunLog } from "./work.mjs";
|
|
15
15
|
import { repoPathMap, laneMap, openWork, detectRepo } from "./scan.mjs";
|
|
@@ -125,6 +125,29 @@ function git(repo, args, timeout = 30000) {
|
|
|
125
125
|
const r = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", timeout, maxBuffer: 32 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
|
126
126
|
return { ok: r.status === 0, out: String(r.stdout || "").replace(/\s+$/, ""), err: String(r.stderr || (r.error && r.error.message) || "").trim() };
|
|
127
127
|
}
|
|
128
|
+
// The same, in the background: Approve's status and add can take minutes in a big
|
|
129
|
+
// tree, and spawnSync held the whole app up until it timed out (ETIMEDOUT). On a
|
|
130
|
+
// timeout git gets SIGTERM first, so it lets go of .git/index.lock.
|
|
131
|
+
function gitAsync(repo, args, timeout = 30000, { input = null, env = {} } = {}) {
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
let out = "", err = "", timedOut = false, done = false, kill = null;
|
|
134
|
+
const p = spawn("git", ["-C", repo, ...args], { env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env }, stdio: [input == null ? "ignore" : "pipe", "pipe", "pipe"] });
|
|
135
|
+
const t = setTimeout(() => { timedOut = true; p.kill("SIGTERM"); kill = setTimeout(() => p.kill("SIGKILL"), 5000); }, timeout);
|
|
136
|
+
const end = (code, e) => { if (done) return; done = true; clearTimeout(t); clearTimeout(kill); resolve({ ok: code === 0 && !timedOut, out: out.replace(/\s+$/, ""), err: String(err || (e && e.message) || "").trim(), timedOut, timeout }); };
|
|
137
|
+
p.stdout.setEncoding("utf8"); p.stderr.setEncoding("utf8");
|
|
138
|
+
p.stdout.on("data", (d) => { out += d; }); p.stderr.on("data", (d) => { err += d; });
|
|
139
|
+
p.on("error", (e) => end(-1, e)); p.on("close", (code) => end(code));
|
|
140
|
+
if (input != null) { p.stdin.on("error", () => {}); p.stdin.end(input); }
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// At least 2 minutes, and more for a long list of paths: 2 ms a path, up to 10.
|
|
144
|
+
const gitTimeout = (n = 0) => Math.min(600000, 120000 + 2 * n);
|
|
145
|
+
// What to tell the user when git didn't finish: never a bare ETIMEDOUT.
|
|
146
|
+
function gitFailed(what, r, n = 0) {
|
|
147
|
+
if (!r.timedOut) return `${what} failed: ` + (r.err.split("\n").filter(Boolean).pop() || "unknown error");
|
|
148
|
+
const many = n > 5000 ? `this repo has about ${n.toLocaleString("en")} changed or untracked files` : "this repo may have a big untracked tree";
|
|
149
|
+
return `${what} timed out after ${Math.round(r.timeout / 1000)} s: ${many}, probably a venv, node_modules, a dataset or a cache missing from .gitignore. Add it to .gitignore, then approve again.`;
|
|
150
|
+
}
|
|
128
151
|
const NOT_SYMBIOT = ["--", ".", ":(exclude).symbiot"]; // .symbiot/ is Symbiot's scratch (brief + agent log), never shipped
|
|
129
152
|
function workingChanges(path) {
|
|
130
153
|
const st = git(path, ["status", "--porcelain", "-uall", ...NOT_SYMBIOT]);
|
|
@@ -134,6 +157,31 @@ function workingChanges(path) {
|
|
|
134
157
|
const stat = [ins && `+${ins}`, del && `−${del}`, fresh && `${fresh} new`].filter(Boolean).join(" ");
|
|
135
158
|
return { branch: git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).out, files, stat };
|
|
136
159
|
}
|
|
160
|
+
// `git status --porcelain -z`: [{ st, file, from }], from being a rename's old path.
|
|
161
|
+
function parseStatusZ(out) {
|
|
162
|
+
const parts = String(out || "").split("\0"), files = [];
|
|
163
|
+
for (let i = 0; i < parts.length; i++) {
|
|
164
|
+
const l = parts[i]; if (l.length < 4) continue;
|
|
165
|
+
const f = { st: l.slice(0, 2).trim(), file: l.slice(3) };
|
|
166
|
+
if (/^[RC]/.test(l)) f.from = parts[++i];
|
|
167
|
+
files.push(f);
|
|
168
|
+
}
|
|
169
|
+
return files;
|
|
170
|
+
}
|
|
171
|
+
// The paths Approve stages: every tracked change, and the untracked files the
|
|
172
|
+
// agent's runs made. An untracked file that was already there before the first
|
|
173
|
+
// run since the last approve (agents.mjs untrackedBefore) and hasn't changed
|
|
174
|
+
// since is left out: scratch's 170k files of venvs and datasets timed out
|
|
175
|
+
// `git add -A`, and staging the whole tree took in an unignored .env too. With
|
|
176
|
+
// no note of what was there (a run started before Symbiot kept one), it's
|
|
177
|
+
// every change.
|
|
178
|
+
function taskPaths(path, files) {
|
|
179
|
+
const before = untrackedBefore(path), had = before ? new Set(before.files) : null;
|
|
180
|
+
const changed = (f) => { try { return statSync(join(path, f)).mtimeMs >= before.at; } catch { return true; } };
|
|
181
|
+
const mine = (f) => f.st !== "??" || !had || !had.has(f.file) || changed(f.file);
|
|
182
|
+
const keep = files.filter(mine);
|
|
183
|
+
return { paths: keep.flatMap((f) => (f.from ? [f.file, f.from] : [f.file])), left: files.length - keep.length };
|
|
184
|
+
}
|
|
137
185
|
// The full diff the agent left behind: tracked changes + new files, capped.
|
|
138
186
|
function workingDiff(path, cap = 400000) {
|
|
139
187
|
let d = git(path, ["diff", "HEAD", ...NOT_SYMBIOT]).out;
|
|
@@ -332,14 +380,21 @@ function releaseInput(repo, opts = {}) {
|
|
|
332
380
|
// Ship with the version bumped when opts.bump is "patch" or "minor" and the repo
|
|
333
381
|
// is offered one (bumpOffer), noted in its CHANGELOG.md if it keeps one (in
|
|
334
382
|
// opts.notes' words, when given). Both are put back if the ship fails.
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
383
|
+
// One ship at a time per folder: Approve no longer holds the app up while git
|
|
384
|
+
// runs, so a second click could start another on the same tree.
|
|
385
|
+
const SHIPPING = new Set();
|
|
386
|
+
async function shipWithBump(path, texts, opts) {
|
|
387
|
+
if (SHIPPING.has(path)) return { error: "Already approving here: wait for that one to finish." };
|
|
388
|
+
SHIPPING.add(path);
|
|
389
|
+
try {
|
|
390
|
+
const offer = (opts.bump === "patch" || opts.bump === "minor") && bumpOffer(path);
|
|
391
|
+
if (!offer) return await shipChanges(path, texts, opts);
|
|
392
|
+
const to = offer[opts.bump], files = workingChanges(path).files.map((f) => f.file);
|
|
393
|
+
const undo = setVersion(path, to), undoLog = noteChangelog(path, to, texts, files, opts.notes);
|
|
394
|
+
const r = await shipChanges(path, texts, { ...opts, bumped: to });
|
|
395
|
+
if (r.error) { undo(); undoLog(); return r; }
|
|
396
|
+
return { ...r, bumped: to };
|
|
397
|
+
} finally { SHIPPING.delete(path); }
|
|
343
398
|
}
|
|
344
399
|
// What the last run in a folder said when it finished: agent.log after the last
|
|
345
400
|
// run's "=== name time ===" and "$ command" lines.
|
|
@@ -427,9 +482,13 @@ function commitSubject(texts) {
|
|
|
427
482
|
// one instead (mergedAlready). Each step that can't happen (no remote, push
|
|
428
483
|
// rejected, no gh) stops there and says so — the commit is never lost.
|
|
429
484
|
// opts.push=false stops after the commit, opts.pr=false after the push.
|
|
430
|
-
function shipChanges(path, texts, opts = {}) {
|
|
431
|
-
const
|
|
485
|
+
async function shipChanges(path, texts, opts = {}) {
|
|
486
|
+
const st = await gitAsync(path, ["status", "--porcelain", "-z", "-uall", ...NOT_SYMBIOT], gitTimeout());
|
|
487
|
+
if (!st.ok) return { error: gitFailed("git status", st) };
|
|
488
|
+
const ch = { files: parseStatusZ(st.out), branch: git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).out };
|
|
432
489
|
if (!ch.files.length) return { ok: true, nothing: true, note: "No uncommitted changes — approved without a commit." };
|
|
490
|
+
const { paths, left } = taskPaths(path, ch.files), leftNote = left ? `${left.toLocaleString("en")} untracked file${left === 1 ? " that was" : "s that were"} already there before the agent ran ${left === 1 ? "was" : "were"} left out.` : "";
|
|
491
|
+
if (!paths.length) return { ok: true, nothing: true, note: "No changes from the agent's runs — approved without a commit. " + leftNote };
|
|
433
492
|
if (!ch.branch || ch.branch === "HEAD") return { error: "Detached HEAD — check out a branch first." };
|
|
434
493
|
const base = gitDefaultBranch(path), day = new Date().toISOString().slice(0, 10); let branch = ch.branch, fresh = "";
|
|
435
494
|
const newBranch = () => {
|
|
@@ -453,17 +512,20 @@ function shipChanges(path, texts, opts = {}) {
|
|
|
453
512
|
if (sw.ok) branch = next;
|
|
454
513
|
}
|
|
455
514
|
}
|
|
456
|
-
// Stage
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
const add =
|
|
515
|
+
// Stage just the task's paths (taskPaths), in the background, given on stdin
|
|
516
|
+
// (170k of them don't fit in an argv) and taken literally (a `[` or `*` in a
|
|
517
|
+
// name isn't a glob). They come from a status that leaves .symbiot out; the
|
|
518
|
+
// reset also covers anything of it staged before.
|
|
519
|
+
const add = await gitAsync(path, ["add", "-A", "--pathspec-from-file=-", "--pathspec-file-nul"], gitTimeout(paths.length), { input: paths.join("\0"), env: { GIT_LITERAL_PATHSPECS: "1" } });
|
|
520
|
+
if (!add.ok) return { error: gitFailed("git add", add, paths.length), branch };
|
|
461
521
|
git(path, ["reset", "-q", "--", ".symbiot"]); // never ship Symbiot's own scratch
|
|
462
522
|
const subject = commitSubject(texts);
|
|
463
523
|
const body = (texts.length ? texts.map((x) => "- " + x).join("\n") : "No ticked task covers these changes.") + (opts.bumped ? `\n\nBumps the version to ${opts.bumped}. ` + (publishesOnMerge(path) ? `It publishes to npm when this merges.` : `After this merges, tag v${opts.bumped} on ${base} to release it.`) : "");
|
|
464
524
|
const cm = git(path, ["commit", "-m", subject, "-m", body]); if (!cm.ok) return { error: "Commit failed: " + (cm.err || cm.out), branch };
|
|
465
|
-
|
|
466
|
-
const
|
|
525
|
+
forgetUntracked(path); // committed: the next run notes what's there afresh
|
|
526
|
+
const said = [fresh, leftNote].filter(Boolean).join(" ");
|
|
527
|
+
const out = { ok: true, branch, base, commit: git(path, ["rev-parse", "--short", "HEAD"]).out, subject, ...(said ? { note: said } : {}) };
|
|
528
|
+
const noted = (note) => ({ ...out, note: (said ? said + " " : "") + note });
|
|
467
529
|
if (opts.push === false) return out;
|
|
468
530
|
if (!git(path, ["remote", "get-url", "origin"]).ok) return noted("No origin remote — committed locally.");
|
|
469
531
|
let ps = git(path, ["push", "-u", "origin", branch], 120000);
|
|
@@ -495,14 +557,16 @@ function shipChanges(path, texts, opts = {}) {
|
|
|
495
557
|
// Per-repo opt-in to auto-merge Approve PRs once CI passes (default off).
|
|
496
558
|
function autoMergeRepos() { const a = loadConfig().autoMerge; return Array.isArray(a) ? a : []; }
|
|
497
559
|
function setAutoMerge(repo, on) { const cfg = loadConfig(); let a = (Array.isArray(cfg.autoMerge) ? cfg.autoMerge : []).filter((x) => x !== repo); if (on && repo) a.push(repo); if (a.length) cfg.autoMerge = a; else delete cfg.autoMerge; saveConfig(cfg); return { ok: true, repos: cfg.autoMerge || [] }; }
|
|
498
|
-
function approveRepo(repo, opts = {}) {
|
|
499
|
-
const
|
|
500
|
-
|
|
560
|
+
async function approveRepo(repo, opts = {}) {
|
|
561
|
+
const waiting = (t) => t.filter((x) => x.repo === repo && x.review && !x.done && !x.archived);
|
|
562
|
+
const shipped = waiting(loadTasks());
|
|
563
|
+
if (!shipped.length) return { error: "Nothing awaiting review for " + (repo || "(no repo)") + "." };
|
|
501
564
|
const path = laneMap()[repo]; if (!path) return { error: "Repo not found: " + repo };
|
|
502
565
|
const busy = stillWorking(repo, path); if (busy) return busy;
|
|
503
|
-
const r = shipWithBump(path, uniqueTasks(
|
|
566
|
+
const r = await shipWithBump(path, uniqueTasks(shipped.map((x) => x.text)), { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
|
|
504
567
|
if (r.error) return r;
|
|
505
|
-
|
|
568
|
+
// read again: the ship took a while, and the list may have changed meanwhile
|
|
569
|
+
const ids = new Set(shipped.map((x) => x.id)), t = loadTasks(), items = waiting(t).filter((x) => ids.has(x.id)), now = Date.now();
|
|
506
570
|
for (const x of items) { x.review = false; x.done = true; x.archived = true; x.archivedAt = now; x.approvedAt = now; for (const k of ["branch", "commit", "pr"]) if (r[k]) x[k] = r[k]; }
|
|
507
571
|
const removed = applyRemovals(t);
|
|
508
572
|
saveTasks(t);
|
|
@@ -513,14 +577,14 @@ function approveRepo(repo, opts = {}) {
|
|
|
513
577
|
// awaiting review in the repo, this is just Approve. opts.tick: ids of the
|
|
514
578
|
// repo's open tasks the changes finished (the run didn't tick them): they're
|
|
515
579
|
// approved with the changes, as Approve does, so they don't go out again.
|
|
516
|
-
function approveChanges(repo, opts = {}) {
|
|
517
|
-
if (loadTasks().some((x) => x.repo === repo && x.review && !x.done && !x.archived)) return approveRepo(repo, opts);
|
|
580
|
+
async function approveChanges(repo, opts = {}) {
|
|
581
|
+
if (loadTasks().some((x) => x.repo === repo && x.review && !x.done && !x.archived)) return await approveRepo(repo, opts);
|
|
518
582
|
const path = laneMap()[repo]; if (!path) return { error: "Repo not found: " + (repo || "(no repo)") };
|
|
519
583
|
const busy = stillWorking(repo, path); if (busy) return busy;
|
|
520
584
|
if (!workingChanges(path).files.length) return { error: "No uncommitted changes in " + repo + "." };
|
|
521
585
|
const ids = new Set(Array.isArray(opts.tick) ? opts.tick.map(String) : []);
|
|
522
586
|
const ticked = (t) => t.filter((x) => ids.has(x.id) && x.repo === repo && !x.done && !x.archived && !x.review);
|
|
523
|
-
const r = shipWithBump(path, uniqueTasks(ticked(loadTasks()).map((x) => x.text)), { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
|
|
587
|
+
const r = await shipWithBump(path, uniqueTasks(ticked(loadTasks()).map((x) => x.text)), { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
|
|
524
588
|
if (r.error) return r;
|
|
525
589
|
// read again: the ship took a while, and the list may have changed meanwhile
|
|
526
590
|
const t = loadTasks(), items = ticked(t), now = Date.now();
|
|
@@ -565,6 +629,9 @@ function buildTasksMd(name, ctx, list) {
|
|
|
565
629
|
if (ctx.connectors) L.push(`- **Connectors:** ${ctx.connectors}`);
|
|
566
630
|
if (ctx.about && ctx.about.length) L.push("", ...ctx.about);
|
|
567
631
|
L.push("", "## Tasks");
|
|
632
|
+
// urgent first, whatever kind it is (a crash fix filed under Docs waited behind 5 tasks, 2026-10-08)
|
|
633
|
+
const urgent = list.filter((t) => isUrgent(t.text)); list = list.filter((t) => !urgent.includes(t));
|
|
634
|
+
if (urgent.length) { L.push("### Urgent: do these first", "Stop at a safe point in anything else you're on (your changes stay), do these, then carry on with the rest."); for (const t of urgent) L.push(`- [ ] ${t.text}`); L.push(""); }
|
|
568
635
|
const byType = {}; for (const t of list) { const ty = taskType(t.text); (byType[ty] = byType[ty] || []).push(t); }
|
|
569
636
|
const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
|
|
570
637
|
for (const ty of keys) { L.push(`### ${ty}`); for (const t of byType[ty]) L.push(`- [ ] ${t.text}`); L.push(""); }
|
|
@@ -630,4 +697,4 @@ function pushTasks(filter) {
|
|
|
630
697
|
return { empty: false, written, unresolved, handoff: handoffCmd() };
|
|
631
698
|
}
|
|
632
699
|
|
|
633
|
-
export { addTask, toggleTask, removeTask, restoreTask, completedInRepo, removalOf, applyRemovals, applyDrops, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, changelogEntry, changelogSection, noteChangelog, releaseInput, shipWithBump, runSummary, saidFinished, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
|
|
700
|
+
export { gitFailed, taskPaths, addTask, toggleTask, removeTask, restoreTask, completedInRepo, removalOf, applyRemovals, applyDrops, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, changelogEntry, changelogSection, noteChangelog, releaseInput, shipWithBump, runSummary, saidFinished, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
|