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.
Files changed (4) hide show
  1. package/agents.mjs +40 -11
  2. package/index.mjs +2 -1
  3. package/package.json +1 -1
  4. 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
- releaseHeldTasks(repoPath);
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.35.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 &quot;{prompt}&quot; --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 &mdash; 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 &mdash; 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'>&times;</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);