symbiot 0.35.0 → 0.36.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/agents.mjs +40 -11
- package/index.mjs +2 -1
- package/package.json +1 -1
- package/ui.mjs +11 -0
package/agents.mjs
CHANGED
|
@@ -33,37 +33,61 @@ function setHandoffCmd(cmd) {
|
|
|
33
33
|
delete cfg.ide; saveConfig(cfg);
|
|
34
34
|
return { ok: true, cmd: cfg.agentCmd || "" };
|
|
35
35
|
}
|
|
36
|
+
// Grant a blocked agent what it asked for, instead of hand-editing the command:
|
|
37
|
+
// `tool` (a command like "python3" → Bash(python3:*), or a full "Tool(spec)")
|
|
38
|
+
// joins --allowedTools; `dir` joins --add-dir. Claude only (that's where these
|
|
39
|
+
// flags live); for another agent we say to edit the command directly.
|
|
40
|
+
function grantAgent({ tool, dir } = {}) {
|
|
41
|
+
const cfg = loadConfig(); let cmd = (cfg.agentCmd || CLAUDE_CMD).trim();
|
|
42
|
+
if (!/^\s*claude\b/.test(cmd)) return { error: "Grants apply to the Claude agent command. Pick a Claude preset first, or edit the command directly." };
|
|
43
|
+
if (tool) {
|
|
44
|
+
let t = String(tool).trim();
|
|
45
|
+
if (t && !/^[A-Za-z]+\(/.test(t)) t = `Bash(${t.replace(/[()"]/g, "")}:*)`; // bare word -> Bash(word:*)
|
|
46
|
+
if (t && !cmd.includes(`"${t}"`)) {
|
|
47
|
+
if (/--allowedTools\b/.test(cmd)) cmd = cmd.replace(/(--allowedTools\s+(?:"[^"]*"\s*)+)/, (m) => m.trimEnd() + ` "${t}" `);
|
|
48
|
+
else cmd += ` --allowedTools "${t}"`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (dir) { const d = String(dir).trim(); if (d && !cmd.includes(`--add-dir "${d}"`)) cmd += ` --add-dir "${d}"`; }
|
|
52
|
+
cmd = cmd.replace(/\s+/g, " ").trim();
|
|
53
|
+
cfg.agentCmd = cmd; saveConfig(cfg);
|
|
54
|
+
return { ok: true, cmd };
|
|
55
|
+
}
|
|
36
56
|
const fillHandoff = (tmpl, repoPath) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
|
|
37
57
|
// One agent per folder: two identical runs once started on the same repo 6s
|
|
38
58
|
// apart and raced each other. The registry catches a second click in this
|
|
39
59
|
// process; .symbiot/agent.pid catches another one (`symbiot push --open` while
|
|
40
60
|
// the app is up). Returns the job, null (no command set) or { busy, id, pid }.
|
|
41
61
|
// A preset that only opens a tab (Orca, editors) exits at once, so it holds
|
|
42
|
-
// the folder only that long.
|
|
62
|
+
// the folder only that long. A busy result's `auto` says whether the process
|
|
63
|
+
// that started that agent is still up to start one on the held tasks.
|
|
43
64
|
function runHandoff(repoPath) {
|
|
44
65
|
const tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
|
|
45
|
-
const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid };
|
|
66
|
+
const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid, auto: !!busy.auto };
|
|
46
67
|
releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
|
|
47
68
|
const lock = join(repoPath, ".symbiot", LOCK);
|
|
48
69
|
const e = track(repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath, () => {
|
|
49
70
|
try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
|
|
50
|
-
|
|
71
|
+
// tasks sent while it ran land now; start on them as that Send would have
|
|
72
|
+
if (releaseHeldTasks(repoPath) && /^\s*-\s*\[ \]/m.test(readSymbiot(repoPath, "TASKS.md"))) { const n = runHandoff(repoPath); if (n && !n.busy) n.fromHeld = true; }
|
|
51
73
|
});
|
|
52
74
|
if (!e) return null;
|
|
53
75
|
e.handoff = true;
|
|
54
|
-
if (e.pid) try { writeFileSync(lock, JSON.stringify({ pid: e.pid, id: e.id, startedAt: e.startedAt })); } catch {}
|
|
76
|
+
if (e.pid) try { writeFileSync(lock, JSON.stringify({ pid: e.pid, id: e.id, startedAt: e.startedAt, owner: process.pid })); } catch {}
|
|
55
77
|
return e;
|
|
56
78
|
}
|
|
57
79
|
const LOCK = "agent.pid";
|
|
58
80
|
const LOCK_MAX_AGE = 12 * 3600 * 1000; // older than this, the pid has probably been reused
|
|
59
81
|
const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (err) { return !!err && err.code === "EPERM"; } };
|
|
60
|
-
// The agent still running in this folder, if any.
|
|
82
|
+
// The agent still running in this folder, if any. `auto`: its exit will be
|
|
83
|
+
// seen (this process started it, or its owner — e.g. the app — is still up).
|
|
84
|
+
// `symbiot push --open` returns right after starting one, so it never is.
|
|
61
85
|
function runningHandoff(path) {
|
|
62
86
|
const e = HANDOFFS.find((x) => x.handoff && x.path === path && x.status === "running");
|
|
63
|
-
if (e) return { id: e.id, pid: e.pid, startedAt: e.startedAt };
|
|
87
|
+
if (e) return { id: e.id, pid: e.pid, startedAt: e.startedAt, auto: true };
|
|
64
88
|
try {
|
|
65
89
|
const l = JSON.parse(readSymbiot(path, LOCK));
|
|
66
|
-
if (l && Number.isInteger(l.pid) && Date.now() - l.startedAt < LOCK_MAX_AGE && pidAlive(l.pid)) return l;
|
|
90
|
+
if (l && Number.isInteger(l.pid) && Date.now() - l.startedAt < LOCK_MAX_AGE && pidAlive(l.pid)) return { ...l, auto: Number.isInteger(l.owner) && pidAlive(l.owner) };
|
|
67
91
|
} catch {}
|
|
68
92
|
return null;
|
|
69
93
|
}
|
|
@@ -257,7 +281,7 @@ function parseQuestions(md) {
|
|
|
257
281
|
if (/^#\s/.test(l)) continue;
|
|
258
282
|
const b = l.match(/^(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.+)$/);
|
|
259
283
|
if (sec === "s") { if (b) suggestions.push(clip(b[1])); continue; }
|
|
260
|
-
if (b) { if (cur) cur.options.push(clip(b[1])); else questions.push({ q: clip(b[1]), context: "", options: [] }); continue; }
|
|
284
|
+
if (b) { if (cur) cur.options.push(clip(b[1])); else if (/\?\s*$/.test(b[1])) questions.push({ q: clip(b[1]), context: "", options: [] }); continue; } // a bullet with no "### question" above it is a question only if it actually ends in "?" — otherwise it's preamble/prose (a file list, etc.)
|
|
261
285
|
if (cur) cur.context = clip((cur.context ? cur.context + " " : "") + l);
|
|
262
286
|
}
|
|
263
287
|
return { questions: questions.filter((x) => x.q).slice(0, 20).map((x) => ({ ...x, options: x.options.slice(0, 6) })), suggestions: suggestions.filter(Boolean).slice(0, 10) };
|
|
@@ -294,15 +318,20 @@ function answerQuestions(path, answers, opts = {}) {
|
|
|
294
318
|
if (opts.rerun) { const e = runHandoff(path); if (e && e.busy) out.note = "Answers saved. An agent is still running in that folder, so another wasn't started. Send them again once it finishes."; else if (e) out.rerun = e.id; else out.note = "Answers saved. Set an agent command in Settings to have the agent pick them up automatically."; }
|
|
295
319
|
return out;
|
|
296
320
|
}
|
|
321
|
+
// Open tasks in a held brief (TASKS.next.md), or null if nothing is held.
|
|
322
|
+
function heldTasks(path) {
|
|
323
|
+
const md = readSymbiot(path, HELD);
|
|
324
|
+
return md ? md.split("\n").filter((l) => /^\s*-\s*\[ \]/.test(l)).length : null;
|
|
325
|
+
}
|
|
297
326
|
// The Agents tab: every tracked job, its log tail, what it changed, and — on the
|
|
298
|
-
// newest job per folder — the questions/ideas it left.
|
|
327
|
+
// newest job per folder — the questions/ideas it left and any tasks held for it.
|
|
299
328
|
function agentsList() {
|
|
300
329
|
const seen = new Set();
|
|
301
330
|
return HANDOFFS.map((e) => {
|
|
302
331
|
let tail = ""; try { tail = readFileSync(e.log, "utf8").slice(-1200); } catch {}
|
|
303
332
|
const first = !seen.has(e.path); seen.add(e.path);
|
|
304
|
-
return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null };
|
|
333
|
+
return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, fromHeld: !!e.fromHeld };
|
|
305
334
|
});
|
|
306
335
|
}
|
|
307
336
|
|
|
308
|
-
export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, fillHandoff, runHandoff, runningHandoff, writeTasks, releaseHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, agentQuestions, answerQuestions, agentsList };
|
|
337
|
+
export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, fillHandoff, runHandoff, runningHandoff, writeTasks, releaseHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, agentQuestions, answerQuestions, agentsList };
|
package/index.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
30
30
|
import { EMBEDDED_UI } from "./ui.mjs";
|
|
31
31
|
import { detectMailSources, mailActivity } from "./mail.mjs";
|
|
32
32
|
import { CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, sh, hasCmd, repoState } from "./core.mjs";
|
|
33
|
-
import { HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, fillHandoff, runHandoff, writeTasks, releaseHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, parseQuestions, agentQuestions, answerQuestions, agentsList } from "./agents.mjs";
|
|
33
|
+
import { HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, fillHandoff, runHandoff, writeTasks, releaseHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, parseQuestions, agentQuestions, answerQuestions, agentsList } from "./agents.mjs";
|
|
34
34
|
import { gitDefaultBranch, loadDeploys, driftRepo } from "./drift.mjs";
|
|
35
35
|
|
|
36
36
|
const HERE = fileURLToPath(new URL(".", import.meta.url));
|
|
@@ -1484,6 +1484,7 @@ async function cmdApp() {
|
|
|
1484
1484
|
if (u.pathname === "/api/scanroots/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeScanRoot(String(b.path || ""))); }
|
|
1485
1485
|
if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors }); }
|
|
1486
1486
|
if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); return json(res, setHandoffCmd(b.cmd)); }
|
|
1487
|
+
if (u.pathname === "/api/agent/grant" && req.method === "POST") { const b = await readBody(req); return json(res, grantAgent({ tool: b.tool, dir: b.dir })); }
|
|
1487
1488
|
if (u.pathname === "/api/open" && req.method === "POST") { const b = await readBody(req); const e = runHandoff(String(b.path || "")); return json(res, { opened: !!e && !e.busy, busy: !!(e && e.busy), id: e ? e.id : "" }); }
|
|
1488
1489
|
if (u.pathname === "/api/setup-local" && req.method === "POST") {
|
|
1489
1490
|
const b = await readBody(req);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.1",
|
|
4
4
|
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/ui.mjs
CHANGED
|
@@ -214,6 +214,12 @@ label.check input{width:auto}
|
|
|
214
214
|
<input id="agentcmd" type="text" placeholder="e.g. aider --message "{prompt}" --yes · code {dir} · leave blank to just write the file">
|
|
215
215
|
<div class="note muted">Runs in each repo after tasks are written. Use <b>{dir}</b> = repo path, <b>{prompt}</b> = the task instruction. Works with any agent or editor — it's your command.</div>
|
|
216
216
|
<div id="agentpresets" style="margin-top:8px"></div>
|
|
217
|
+
<div id="grantbox" style="margin-top:10px">
|
|
218
|
+
<label style="font-size:12px;color:var(--faint)">When an agent asks to run something or read a folder, grant it here instead of editing the command:</label>
|
|
219
|
+
<div class="row" style="margin-top:6px"><input id="granttool" placeholder="allow a command, e.g. python3 or pytest" style="flex:1"><button class="ghost" id="granttoolbtn">Allow command</button></div>
|
|
220
|
+
<div class="row" style="margin-top:6px"><input id="grantdir" placeholder="allow a folder, e.g. /home/you/GoSolr" style="flex:1"><button class="ghost" id="grantdirbtn">Allow folder</button></div>
|
|
221
|
+
<div class="note" id="grantnote"></div>
|
|
222
|
+
</div>
|
|
217
223
|
</div>
|
|
218
224
|
<div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
|
|
219
225
|
<label>Email — add what you sent to Week and Standup <span class="muted">(experimental)</span></label>
|
|
@@ -500,6 +506,7 @@ if(chips.length>na)h+="<div class='muted' style='font-size:12px;margin-top:8px'>
|
|
|
500
506
|
box.innerHTML=h;
|
|
501
507
|
box.querySelectorAll('.preset').forEach(function(btn){btn.addEventListener('click',function(){document.getElementById('agentcmd').value=chips[+btn.getAttribute('data-i')].tmpl;saveAgent();});});});}
|
|
502
508
|
function saveAgent(){api('/api/agentcmd',{cmd:document.getElementById('agentcmd').value});}
|
|
509
|
+
function grant(kind){var i=document.getElementById(kind==='tool'?'granttool':'grantdir');var v=(i.value||'').trim();if(!v)return;var body={};body[kind]=v;api('/api/agent/grant',body).then(function(r){var n=document.getElementById('grantnote');if(!r||r.error){n.className='note err';n.textContent=(r&&r.error)||'could not grant';return;}i.value='';n.className='note ok';n.textContent=(kind==='tool'?'Command allowed':'Folder allowed')+' — the agent can use it on its next run.';if(r.cmd)document.getElementById('agentcmd').value=r.cmd;});}
|
|
503
510
|
function loadScanRoots(){api('/api/scanroots').then(function(d){var box=document.getElementById('scanroots');var roots=d.effective||[];
|
|
504
511
|
box.innerHTML=roots.map(function(r){var custom=(d.roots||[]).indexOf(r)>=0;return "<div class='task' data-p='"+esc(r)+"'><span class='t' style='font-family:ui-monospace,monospace;font-size:12px'>"+esc(r)+"</span>"+(r===d.home?"<span class='rp'>home</span>":"")+(custom?"<button class='rm rmroot' title='remove'>×</button>":"")+"</div>";}).join("");
|
|
505
512
|
box.querySelectorAll('.rmroot').forEach(function(btn){btn.addEventListener('click',function(){api('/api/scanroots/remove',{path:btn.closest('.task').getAttribute('data-p')}).then(function(){loadScanRoots();mapLoaded=false;driftLoaded=false;});});});});}
|
|
@@ -565,6 +572,10 @@ document.getElementById('agentsrefresh').addEventListener('click',loadAgents);
|
|
|
565
572
|
document.getElementById('addtask').addEventListener('click',addTaskUI);
|
|
566
573
|
document.getElementById('pushtasks').addEventListener('click',pushTasksUI);
|
|
567
574
|
document.getElementById('agentcmd').addEventListener('change',saveAgent);
|
|
575
|
+
document.getElementById('granttoolbtn').addEventListener('click',function(){grant('tool');});
|
|
576
|
+
document.getElementById('grantdirbtn').addEventListener('click',function(){grant('dir');});
|
|
577
|
+
document.getElementById('granttool').addEventListener('keydown',function(e){if(e.key==='Enter')grant('tool');});
|
|
578
|
+
document.getElementById('grantdir').addEventListener('keydown',function(e){if(e.key==='Enter')grant('dir');});
|
|
568
579
|
document.getElementById('mailon').addEventListener('change',setMailOn);
|
|
569
580
|
document.getElementById('mailaddrs').addEventListener('change',saveMailAddrs);
|
|
570
581
|
document.getElementById('addmail').addEventListener('click',addMailUI);
|