symbiot 0.41.5 → 0.41.7

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/README.md CHANGED
@@ -267,6 +267,16 @@ Tasks waiting for review aren't re-sent to the agent, and while the repo's agent
267
267
  is still running, Approve waits ("agent still working"), so half-done work isn't
268
268
  committed.
269
269
 
270
+ **Tasks about tasks.** An agent can't edit your task list, so a task like
271
+ `Drop the "gosolr's own WhatsApp number" task` or ``Merge the two `WA_WABA_ID`
272
+ tasks into one`` is done once you approve it. The tasks it names then leave your
273
+ list, so a later **Send to repos** doesn't bring them back: a dropped one is
274
+ archived, and of merged ones the newest stays and the rest are archived. Symbiot
275
+ finds them by the quoted name before the word "task": tasks in that repo, from
276
+ before the approval, with all of its words ("'s" aside). If it names more than
277
+ four, it's too vague, and nothing is removed. In the archived view they're marked
278
+ **dropped** or **merged**, and one you restore stays.
279
+
270
280
  **Releases.** The review card warns about merged work that isn't released yet,
271
281
  measured the way the repo releases: from its last `v*` tag, or, for a repo whose
272
282
  GitHub workflow runs `npm publish` on every push to its default branch, from the
@@ -315,6 +325,18 @@ preset only fills in the command box; the saved command is what runs.
315
325
  isn't on your PATH). They just open the repo. Nothing comes back for review unless
316
326
  you run an agent there yourself, and Neovim needs a terminal to open in.
317
327
 
328
+ **Connectors.** The connectors you've linked to Claude (claude.ai's Google Drive,
329
+ Gmail, Notion…, and servers added with `claude mcp add`) are only usable by an
330
+ unattended run when `--allowedTools` names them, so a run asked to check your
331
+ Drive or mail used to be refused. Symbiot now adds each one's rule
332
+ (`mcp__claude_ai_Google_Drive` covers `mcp__claude_ai_Google_Drive__search_files`
333
+ and the rest of its tools) to a Claude command on every run. It reads them from
334
+ `~/.claude.json`, so linking one takes effect on the next run, and your saved
335
+ command stays as you typed it. A connector still waiting to be authorized at
336
+ claude.ai isn't added. Settings → Handoff lists them, and says so when your
337
+ command isn't Claude and its runs can't use them. Claude in Orca's tab asks you
338
+ before using one.
339
+
318
340
  Each run is logged to `.symbiot/agent.log` in the repo and shown live in the
319
341
  **Agents** tab. The command is saved as `agentCmd` in
320
342
  `~/.config/symbiot/config.json` — it's your command, Symbiot only fills in
@@ -369,6 +391,16 @@ keep going with everything that doesn't depend on an answer, and to ask instead
369
391
  doing anything destructive. Both files live in `.symbiot/`, so they're never
370
392
  committed.
371
393
 
394
+ **A run that stopped on questions isn't repeated for nothing.** When a run ends
395
+ having asked questions you haven't answered, with tasks still unticked, Symbiot
396
+ keeps what it ran with in `.symbiot/blocked.json`. The next send to that repo
397
+ starts no agent until something it could act on changes: `.env` (or another
398
+ `.env.*` file there), `ANSWERS.md`, the agent command (a command you allowed, a
399
+ connector), or a task it didn't have. Otherwise the run would only ask the same
400
+ questions again. The send says so, with **Start it anyway** for when what changed
401
+ is somewhere else (`symbiot push --open --force` from the terminal). **Draft a
402
+ reply** always starts its run.
403
+
372
404
  ## What's out of sync? `symbiot drift`
373
405
 
374
406
  ```bash
package/agents.mjs CHANGED
@@ -4,9 +4,9 @@
4
4
  import { spawn } from "node:child_process";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
- import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync } from "node:fs";
8
- import { randomBytes } from "node:crypto";
9
- import { loadConfig, saveConfig, loadTasks, sameTask, sh, hasCmd } from "./core.mjs";
7
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync, readdirSync, statSync } from "node:fs";
8
+ import { randomBytes, createHash } from "node:crypto";
9
+ import { loadConfig, saveConfig, loadTasks, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
10
10
 
11
11
  const HANDOFFS = []; // live registry of agents Symbiot has handed work to
12
12
  // ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
@@ -48,21 +48,50 @@ function grantRule(tool) {
48
48
  t = t.replace(/[()"'`]/g, "").replace(/:\*$/, "").trim();
49
49
  return t ? `Bash(${t}:*)` : "";
50
50
  }
51
+ // Add a rule to a Claude command's --allowedTools (or start the flag), once.
52
+ function allowTool(cmd, t) {
53
+ if (!t || cmd.includes(`"${t}"`)) return cmd;
54
+ if (/--allowedTools\b/.test(cmd)) return cmd.replace(/(--allowedTools\s+(?:"[^"]*"\s*)+)/, (m) => m.trimEnd() + ` "${t}" `);
55
+ return cmd + ` --allowedTools "${t}"`;
56
+ }
57
+ const isClaudeCmd = (cmd) => /^\s*claude\b/.test(String(cmd || ""));
51
58
  function grantAgent({ tool, dir } = {}) {
52
59
  const cfg = loadConfig(); let cmd = (cfg.agentCmd || CLAUDE_CMD).trim();
53
- 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." };
54
- if (tool) {
55
- const t = grantRule(tool);
56
- if (t && !cmd.includes(`"${t}"`)) {
57
- if (/--allowedTools\b/.test(cmd)) cmd = cmd.replace(/(--allowedTools\s+(?:"[^"]*"\s*)+)/, (m) => m.trimEnd() + ` "${t}" `);
58
- else cmd += ` --allowedTools "${t}"`;
59
- }
60
- }
60
+ if (!isClaudeCmd(cmd)) return { error: "Grants apply to the Claude agent command. Pick a Claude preset first, or edit the command directly." };
61
+ if (tool) cmd = allowTool(cmd, grantRule(tool));
61
62
  if (dir) { const d = String(dir).trim(); if (d && !cmd.includes(`--add-dir "${d}"`)) cmd += ` --add-dir "${d}"`; }
62
63
  cmd = cmd.replace(/\s+/g, " ").trim();
63
64
  cfg.agentCmd = cmd; saveConfig(cfg);
64
65
  return { ok: true, cmd };
65
66
  }
67
+ // ---- connectors: what the user linked to Claude, for its runs -------------
68
+ // Connectors linked to Claude (claude.ai's Google Drive, Gmail, Notion…, and
69
+ // servers added with `claude mcp add`) are tools to an unattended run only when
70
+ // --allowedTools names them: `claude -p` can't ask, so a run told to check your
71
+ // Drive or mail was refused and said it had no access. From ~/.claude.json: the
72
+ // claude.ai connectors that have connected (claudeAiMcpEverConnected), the
73
+ // user's own servers and this folder's. One waiting to be authorized
74
+ // (mcpNeedsAuthNoticed) is listed but not ready. Its rule, mcp__<name>, allows
75
+ // all its tools; Claude names them with anything but letters, digits, _ and -
76
+ // made _ ("claude.ai Google Drive" -> mcp__claude_ai_Google_Drive__search_files).
77
+ function claudeConnectors(dir = "", file = join(homedir(), ".claude.json")) {
78
+ let j = {}; try { j = JSON.parse(readFileSync(file, "utf8")) || {}; } catch {}
79
+ const names = (x) => Array.isArray(x) ? x.filter((s) => typeof s === "string" && s) : [];
80
+ const servers = (o) => o && typeof o === "object" && !Array.isArray(o) ? Object.keys(o) : [];
81
+ const proj = dir && j.projects && typeof j.projects === "object" ? j.projects[dir] : null;
82
+ const needsAuth = new Set(names(j.mcpNeedsAuthNoticed));
83
+ return [...new Set([...names(j.claudeAiMcpEverConnected), ...servers(j.mcpServers), ...servers(proj && proj.mcpServers)])]
84
+ .map((name) => ({ name, rule: "mcp__" + name.replace(/[^A-Za-z0-9_-]/g, "_"), ready: !needsAuth.has(name) }));
85
+ }
86
+ // A Claude command with the ready connectors' rules added, for this run only:
87
+ // the saved command stays as typed, so linking or unlinking one takes effect on
88
+ // the next run. Any other agent's command runs as-is (Settings says so).
89
+ function withConnectors(tmpl, dir, file) {
90
+ if (!isClaudeCmd(tmpl)) return tmpl;
91
+ return claudeConnectors(dir, file).filter((c) => c.ready).reduce((cmd, c) => allowTool(cmd, c.rule), tmpl).replace(/\s+$/, "");
92
+ }
93
+ // For Settings → Handoff: the connectors, and whether this command's runs get them.
94
+ function connectorsInfo(file) { return { claude: isClaudeCmd(handoffCmd()), list: claudeConnectors("", file).map(({ name, ready }) => ({ name, ready })) }; }
66
95
  const fillHandoff = (tmpl, repoPath) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
67
96
  // One agent per folder: two identical runs once started on the same repo 6s
68
97
  // apart and raced each other. The registry catches a second click in this
@@ -74,15 +103,21 @@ const fillHandoff = (tmpl, repoPath) => tmpl.replace(/\{dir\}/g, shSingle(repoPa
74
103
  // A folder's .symbiot/handoff.json ({ name, env }) names its job and joins its
75
104
  // environment, on every run there, a rerun with answers too: a draft reply's
76
105
  // folder (watch.mjs) carries SYMBIOT_DRAFT, which refuses a press on Send.
77
- function runHandoff(repoPath) {
78
- const tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
106
+ // A folder whose last run stopped on questions, with nothing changed since
107
+ // that could answer them, gets no run: { blocked, questions, note }
108
+ // (blockedAgain). force starts one anyway (something changed elsewhere).
109
+ function runHandoff(repoPath, { force = false } = {}) {
110
+ let tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
79
111
  let opts = {}; try { opts = JSON.parse(readSymbiot(repoPath, "handoff.json")) || {}; } catch {}
80
112
  const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid, auto: !!busy.auto };
81
113
  releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
114
+ tmpl = withConnectors(tmpl, repoPath);
115
+ const blocked = !force && blockedAgain(repoPath, tmpl); if (blocked) return blocked;
82
116
  const lock = join(repoPath, ".symbiot", LOCK);
83
117
  const env = opts.env && typeof opts.env === "object" ? Object.fromEntries(Object.entries(opts.env).map(([k, v]) => [k, String(v)])) : null;
84
- const e = track(typeof opts.name === "string" && opts.name ? opts.name.slice(0, 80) : repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath, () => {
118
+ const e = track(typeof opts.name === "string" && opts.name ? opts.name.slice(0, 80) : repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath, (code) => {
85
119
  try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
120
+ noteBlocked(repoPath, tmpl, e.startedAt, code);
86
121
  startHeldTasks(repoPath); // tasks sent while it ran land now; start on them as that Send would have
87
122
  }, env);
88
123
  if (!e) return null;
@@ -90,6 +125,38 @@ function runHandoff(repoPath) {
90
125
  if (e.pid) try { writeFileSync(lock, JSON.stringify({ pid: e.pid, id: e.id, startedAt: e.startedAt, owner: process.pid })); } catch {}
91
126
  return e;
92
127
  }
128
+ // ---- a run that would only report the same blockers again -------------------
129
+ // A run that ends having asked questions (QUESTIONS.md written during it, some
130
+ // still unanswered) with tasks left unticked is blocked on the user: noteBlocked
131
+ // keeps what it ran with in .symbiot/blocked.json. Another run on the same
132
+ // inputs could only say the same things again, so blockedAgain refuses one
133
+ // until something it could act on changes: .env (the values it asked for),
134
+ // ANSWERS.md, the agent command (a grant, a connector) or a task it didn't have.
135
+ const BLOCKED = "blocked.json";
136
+ const digest = (s) => createHash("sha1").update(String(s)).digest("hex").slice(0, 16);
137
+ const openTasks = (path) => readSymbiot(path, "TASKS.md").split("\n").filter((l) => /^\s*-\s*\[ \]/.test(l)).map((l) => taskWords(l.replace(/^\s*-\s*\[ \]\s*/, ""))).filter(Boolean);
138
+ function runInputs(path, cmd) {
139
+ let files = [], env = ""; try { files = readdirSync(path).filter((f) => /^\.env(\..+)?$/.test(f) && !/\.(example|sample|template)$/.test(f)).sort(); } catch {}
140
+ for (const f of files) { try { env += f + "\0" + readFileSync(join(path, f), "utf8") + "\0"; } catch {} }
141
+ return { env: digest(env), answers: digest(readSymbiot(path, "ANSWERS.md")), cmd: digest(cmd), open: openTasks(path) };
142
+ }
143
+ function noteBlocked(path, cmd, startedAt, code) {
144
+ const f = join(path, ".symbiot", BLOCKED);
145
+ let asked = false; try { asked = statSync(join(path, ".symbiot", "QUESTIONS.md")).mtimeMs >= startedAt; } catch {}
146
+ const questions = asked ? agentQuestions(path, "").questions.length : 0, inputs = runInputs(path, cmd);
147
+ try {
148
+ if (code === 0 && questions && inputs.open.length) writeFileSync(f, JSON.stringify({ at: Date.now(), questions, ...inputs }));
149
+ else unlinkSync(f);
150
+ } catch {}
151
+ }
152
+ function blockedAgain(path, cmd) {
153
+ let b = null; try { b = JSON.parse(readSymbiot(path, BLOCKED)); } catch {}
154
+ if (!b || !Array.isArray(b.open)) return null;
155
+ const now = runInputs(path, cmd), was = new Set(b.open);
156
+ if (now.env !== b.env || now.answers !== b.answers || now.cmd !== b.cmd || !now.open.length || now.open.some((t) => !was.has(t))) return null;
157
+ const n = b.questions || 0, them = n === 1 ? "it" : "them";
158
+ return { blocked: true, questions: n, note: `Not started: the last run here stopped on ${n} question${n === 1 ? "" : "s"}, and .env and ANSWERS.md haven't changed since, so another run would only ask again. Answer ${them} or change .env, then send again.` };
159
+ }
93
160
  const LOCK = "agent.pid";
94
161
  const LOCK_MAX_AGE = 12 * 3600 * 1000; // older than this, the pid has probably been reused
95
162
  const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (err) { return !!err && err.code === "EPERM"; } };
@@ -132,7 +199,7 @@ function releaseHeldTasks(path) {
132
199
  // once, so this can't loop. Returns the new job, or null.
133
200
  function startHeldTasks(path) {
134
201
  if (!releaseHeldTasks(path) || !/^\s*-\s*\[ \]/m.test(readSymbiot(path, "TASKS.md"))) return null;
135
- const n = runHandoff(path); if (!n || n.busy) return null;
202
+ const n = runHandoff(path); if (!n || n.busy || n.blocked) return null;
136
203
  n.fromHeld = true; return n;
137
204
  }
138
205
  // Presets. [cmd, label, macAppName] — macApp used to launch GUI editors on macOS
@@ -348,7 +415,7 @@ function answerQuestions(path, answers, opts = {}) {
348
415
  writeFileSync(join(path, ".symbiot", "ANSWERS.md"), prev.replace(/\s*$/, "\n") + rows.map((x) => `\n### ${x.q}\n${x.a}\n_answered ${day}_\n`).join(""));
349
416
  } catch (e) { return { error: "Couldn't write ANSWERS.md: " + ((e && e.message) || e) }; }
350
417
  const out = { ok: true, saved: rows.length };
351
- 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."; }
418
+ 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 && e.blocked) out.note = "Answers saved. " + e.note; 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."; }
352
419
  return out;
353
420
  }
354
421
  // The open tasks' titles in a held brief (TASKS.next.md), or null if nothing is held.
@@ -367,4 +434,4 @@ function agentsList() {
367
434
  });
368
435
  }
369
436
 
370
- export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, fillHandoff, runHandoff, runningHandoff, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, agentQuestions, answerQuestions, agentsList };
437
+ export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, connectorsInfo, fillHandoff, runHandoff, blockedAgain, runningHandoff, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, agentQuestions, answerQuestions, agentsList };
package/core.mjs CHANGED
@@ -41,12 +41,49 @@ function saveTasks(t) { try { mkdirSync(CONFIG_DIR, { recursive: true }); writeF
41
41
  // with a clause more on the end. An extension only counts when the shorter one
42
42
  // is a whole sentence (8+ words), so "Fix the bug" never swallows "Fix the bug
43
43
  // in the login form".
44
+ // Or nearly the same (nearTask): an agent that suggests a task again rewords
45
+ // it, and TASKS.md listed both.
44
46
  const taskWords = (s) => String(s || "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
45
47
  function sameTask(a, b) {
46
48
  const x = taskWords(a), y = taskWords(b); if (!x || !y) return false;
47
49
  if (x === y) return true;
48
50
  const [s, l] = x.length < y.length ? [x, y] : [y, x];
49
- return s.split(" ").length >= 8 && l.startsWith(s + " ");
51
+ return (s.split(" ").length >= 8 && l.startsWith(s + " ")) || nearTask(a, b);
52
+ }
53
+ // Nearly the same task, two ways:
54
+ // - nearly all the same words in the same order, 12+ words each ("…nothing
55
+ // recorded since 2026-08-03" and "…since 2026-08-04")
56
+ // - the same first sentence of 5+ words, with only what follows it different:
57
+ // "Set `WA_WABA_ID` in `.env`. It's needed to list templates" / "…to create Flows"
58
+ // Never when each names something the other doesn't: "Watch Gmail…" and
59
+ // "Watch WhatsApp…" are two tasks, however alike the rest. A name is a word
60
+ // with a capital that doesn't just start a sentence (Gmail, WhatsApp, WA).
61
+ const NEAR = 0.85, NEAR_WORDS = 12, LEAD_WORDS = 5;
62
+ function wordsOf(s) {
63
+ s = String(s || "");
64
+ return [...s.matchAll(/[\p{L}\p{N}]+/gu)].map((m) => {
65
+ const w = m[0], before = s.slice(0, m.index);
66
+ const starts = !before.trim() || /[.!?]["'`)\]]*\s*$/.test(before);
67
+ return { w: w.toLowerCase(), name: w.length > 1 && /\p{Lu}/u.test(w) && (!starts || /\p{Lu}/u.test(w.slice(1))) };
68
+ });
69
+ }
70
+ // The first sentence's words, when more follows it ("" when it's all one sentence).
71
+ function leadOf(s) { const m = String(s || "").match(/^(.*?[.!?])["'`)\]]*\s+\S/s); return m ? taskWords(m[1]) : ""; }
72
+ function nearTask(a, b) {
73
+ const x = wordsOf(a), y = wordsOf(b), m = x.length, n = y.length; if (!m || !n) return false;
74
+ const lead = leadOf(a), sameLead = !!lead && lead === leadOf(b) && lead.split(" ").length >= LEAD_WORDS;
75
+ if (!sameLead && (Math.min(m, n) < NEAR_WORDS || Math.min(m, n) / Math.max(m, n) < NEAR)) return false;
76
+ // the longest run of words both have in order, and the words left over on each side
77
+ const L = Array.from({ length: m + 1 }, () => new Uint16Array(n + 1));
78
+ for (let i = m - 1; i >= 0; i--) for (let j = n - 1; j >= 0; j--) L[i][j] = x[i].w === y[j].w ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
79
+ if (!sameLead && (2 * L[0][0]) / (m + n) < NEAR) return false;
80
+ let i = 0, j = 0, nx = false, ny = false;
81
+ while (i < m && j < n) {
82
+ if (x[i].w === y[j].w) { i++; j++; } else if (L[i + 1][j] >= L[i][j + 1]) { if (x[i++].name) nx = true; } else if (y[j++].name) ny = true;
83
+ }
84
+ for (; i < m; i++) if (x[i].name) nx = true;
85
+ for (; j < n; j++) if (y[j].name) ny = true;
86
+ return !(nx && ny);
50
87
  }
51
88
  // One of each: near-duplicates collapse into the wording that says the most.
52
89
  function uniqueTasks(texts) {
package/index.mjs CHANGED
@@ -81,9 +81,11 @@ function cmdPush() {
81
81
  if (has("open")) {
82
82
  if (!r.handoff) console.log("\n" + c.y("No agent command set.") + c.d(" Set one in `symbiot app` Settings, or `agentCmd` in ~/.config/symbiot/config.json (use {dir} and {prompt})."));
83
83
  else {
84
- const busy = r.written.map((w) => ({ w, e: runHandoff(w.path) })).filter((x) => x.e && x.e.busy);
85
- const n = r.written.length - busy.length;
84
+ const runs = r.written.map((w) => ({ w, e: runHandoff(w.path, { force: has("force") }) }));
85
+ const busy = runs.filter((x) => x.e && x.e.busy), blocked = runs.filter((x) => x.e && x.e.blocked);
86
+ const n = r.written.length - busy.length - blocked.length;
86
87
  if (n) console.log("\n" + c.g("→ ") + `Handed ${n} repo(s) to your agent (${r.handoff}).`);
88
+ for (const { w, e } of blocked) console.log("\n" + c.y(`${w.name}: `) + e.note + c.d(" (--force starts it anyway)"));
87
89
  for (const { w, e } of busy) console.log("\n" + c.y("Not started: ") + `${w.name} already has an agent running.` + c.d(e.auto ? " Its new tasks are held, and the app starts an agent on them when it finishes." : " Its new tasks are held until it finishes. After that, the app starts an agent on them the next time it checks the repo (opening its Tasks tab), or send again."));
88
90
  }
89
91
  } else {
@@ -522,7 +524,7 @@ ${c.b("Usage")}
522
524
  symbiot todo what's still on your plate
523
525
  symbiot app open the visual app in your browser
524
526
  symbiot drift [--fetch] what's out of sync / at risk across repos
525
- symbiot push [--open] write tasks into each repo (and run your agent)
527
+ symbiot push [--open [--force]] write tasks into each repo (and run your agent)
526
528
  symbiot login connect it to an AI (once)
527
529
  symbiot whoami show how it's connected
528
530
  symbiot logout forget saved credentials
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.41.5",
3
+ "version": "0.41.7",
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/server.mjs CHANGED
@@ -10,7 +10,7 @@ import { createServer } from "node:http";
10
10
  import { randomBytes } from "node:crypto";
11
11
  import { EMBEDDED_UI } from "./ui.mjs";
12
12
  import { VERSION, LATEST_VERSION, semverGt, checkLatest, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary } from "./core.mjs";
13
- import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, answerQuestions, agentsList } from "./agents.mjs";
13
+ import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, agentsList } from "./agents.mjs";
14
14
  import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
15
15
  import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
16
16
  import { computeDrift } from "./drift.mjs";
@@ -161,10 +161,10 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
161
161
  if (u.pathname === "/api/scanroots") return json(res, { roots: loadConfig().scanRoots || [], effective: scanRoots(), home: scanHome() });
162
162
  if (u.pathname === "/api/scanroots/add" && req.method === "POST") { const b = await readBody(req); return json(res, addScanRoot(String(b.path || ""))); }
163
163
  if (u.pathname === "/api/scanroots/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeScanRoot(String(b.path || ""))); }
164
- if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors }); }
164
+ if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors, connectors: connectorsInfo() }); }
165
165
  if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); return json(res, setHandoffCmd(b.cmd)); }
166
166
  if (u.pathname === "/api/agent/grant" && req.method === "POST") { const b = await readBody(req); return json(res, grantAgent({ tool: b.tool, dir: b.dir })); }
167
- 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), auto: !!(e && e.auto), id: e ? e.id : "" }); }
167
+ if (u.pathname === "/api/open" && req.method === "POST") { const b = await readBody(req); const e = runHandoff(String(b.path || ""), { force: !!b.force }); return json(res, { opened: !!e && !e.busy && !e.blocked, busy: !!(e && e.busy), auto: !!(e && e.auto), blocked: !!(e && e.blocked), note: (e && e.note) || "", id: (e && e.id) || "" }); }
168
168
  if (u.pathname === "/api/setup-local" && req.method === "POST") {
169
169
  const b = await readBody(req);
170
170
  if (!hasOllama()) return json(res, { error: "not-installed", install: ollamaInstall() });
package/tasks.mjs CHANGED
@@ -30,13 +30,42 @@ function addTask(text, repo) {
30
30
  }
31
31
  function toggleTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.done = !it.done; saveTasks(t); } return it || { error: "not found" }; }
32
32
  function removeTask(id) { saveTasks(loadTasks().filter((x) => x.id !== id)); return { ok: true }; }
33
- function restoreTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.archived = false; it.done = false; delete it.archivedAt; saveTasks(t); } return it || { error: "not found" }; }
33
+ function restoreTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.archived = false; it.done = false; delete it.archivedAt; if (it.removedBy) { it.kept = true; delete it.removedBy; delete it.merged; } saveTasks(t); } return it || { error: "not found" }; }
34
34
  // Which task texts the agent checked off in a repo's .symbiot/TASKS.md
35
35
  function completedInRepo(repoPath) {
36
36
  try { return readFileSync(join(repoPath, ".symbiot", "TASKS.md"), "utf8").split("\n").filter((l) => /^\s*-\s*\[x\]/i.test(l)).map((l) => l.replace(/^\s*-\s*\[x\]\s*/i, "").trim().toLowerCase()); }
37
37
  catch { return []; }
38
38
  }
39
39
 
40
+ // A task that removes others: `Drop the "gosolr's own WhatsApp number" task`,
41
+ // "Merge the two `WA_WABA_ID` tasks into one". An agent can't edit your task
42
+ // list, so approving one was all that happened: the tasks it named stayed open,
43
+ // and every regenerated TASKS.md brought them back. Once it's approved, they go
44
+ // here: dropped ones are archived, merged ones too, all but the newest. It
45
+ // names them with a quoted phrase (`…`, "…", “…”) whose words are all in
46
+ // theirs ('s aside), and only reaches open tasks in its repo from before its
47
+ // approval. A phrase that names more than MAX_REMOVED is too vague to act on.
48
+ // Archived, each keeps removedBy (and merged); one you restore stays (kept).
49
+ const REMOVES = /^\s*(drop|remove|delete|merge|combine|dedupe|deduplicate)\b/i, MAX_REMOVED = 4;
50
+ const noPossessive = (s) => String(s || "").replace(/['’]s\b/gi, "");
51
+ function removalOf(text) {
52
+ const s = String(text || ""), m = s.match(REMOVES), at = s.search(/\btasks?\b/i); if (!m || at < 0) return null;
53
+ // up to "task": "…the two `WA_WABA_ID` tasks. Both are the same edit to `.env`" names no `.env` task
54
+ const phrases = [...s.slice(0, at).matchAll(/`([^`]+)`|"([^"]+)"|“([^”]+)”/g)].map((x) => taskWords(noPossessive(x[1] || x[2] || x[3]))).filter(Boolean);
55
+ return phrases.length ? { merge: /^(merge|combine|dedupe)/i.test(m[1]), phrases } : null;
56
+ }
57
+ function applyRemovals(t) {
58
+ let n = 0; const now = Date.now();
59
+ for (const r of t.filter((x) => x.approvedAt && x.repo)) {
60
+ const rm = removalOf(r.text); if (!rm) continue;
61
+ const named = (x) => { const w = new Set(taskWords(noPossessive(x.text)).split(" ")); return rm.phrases.some((p) => p.split(" ").every((y) => w.has(y))); };
62
+ const hit = t.filter((x) => x !== r && x.repo === r.repo && !x.done && !x.archived && !x.review && !x.kept && (x.ts || 0) < r.approvedAt && named(x));
63
+ if (hit.length > MAX_REMOVED || (rm.merge && hit.length < 2)) continue;
64
+ const go = rm.merge ? hit.sort((a, b) => (b.ts || 0) - (a.ts || 0)).slice(1) : hit;
65
+ for (const x of go) { Object.assign(x, { done: true, archived: true, archivedAt: now, removedBy: r.id }); if (rm.merge) x.merged = true; n++; }
66
+ }
67
+ return n;
68
+ }
40
69
  // "Check what was handed out, see what's completed, then archive it" — with an
41
70
  // approval step in between. An agent ticking an item in TASKS.md means "done,
42
71
  // please review", NOT archived: it waits in review until the user approves it
@@ -52,8 +81,9 @@ function syncTasks() {
52
81
  if (checkedByRepo[x.repo].some((c) => sameTask(c, x.text))) { x.review = true; x.reviewAt = Date.now(); review++; }
53
82
  }
54
83
  for (const x of t) { if (x.done && !x.archived) { x.archived = true; x.archivedAt = Date.now(); archived++; } }
84
+ const removed = applyRemovals(t);
55
85
  saveTasks(t);
56
- return { review, archived, started };
86
+ return { review, archived, started, removed };
57
87
  }
58
88
  // git with an argv (task text goes into commit messages — never through a shell)
59
89
  function git(repo, args, timeout = 30000) {
@@ -352,8 +382,9 @@ function approveRepo(repo, opts = {}) {
352
382
  if (r.error) return r;
353
383
  const now = Date.now();
354
384
  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]; }
385
+ const removed = applyRemovals(t);
355
386
  saveTasks(t);
356
- return { ...r, approved: items.length };
387
+ return { ...r, approved: items.length, ...(removed ? { removed } : {}) };
357
388
  }
358
389
  // "Approve changes without a task": ship the uncommitted changes even though no
359
390
  // ticked task is behind them (a fix the agent made but didn't tick). If tasks are
@@ -415,7 +446,8 @@ function buildTasksMd(name, ctx, list) {
415
446
  return L.join("\n") + "\n";
416
447
  }
417
448
  function pushTasks(filter) {
418
- let tasks = loadTasks().filter((t) => !t.done && !t.archived && !t.review);
449
+ const all = loadTasks(); if (applyRemovals(all)) saveTasks(all); // what an approved Drop/Merge named never goes out again
450
+ let tasks = all.filter((t) => !t.done && !t.archived && !t.review);
419
451
  if (filter && filter.type) tasks = tasks.filter((t) => taskType(t.text) === filter.type);
420
452
  if (filter && filter.repo) tasks = tasks.filter((t) => t.repo === filter.repo);
421
453
  if (!tasks.length) return { empty: true, written: [], unresolved: [] };
@@ -445,4 +477,4 @@ function pushTasks(filter) {
445
477
  return { empty: false, written, unresolved, handoff: handoffCmd() };
446
478
  }
447
479
 
448
- export { addTask, toggleTask, removeTask, restoreTask, completedInRepo, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, shipWithBump, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
480
+ export { addTask, toggleTask, removeTask, restoreTask, completedInRepo, removalOf, applyRemovals, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, shipWithBump, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
package/ui.mjs CHANGED
@@ -255,6 +255,7 @@ label.check input{width:auto}
255
255
  <input id="agentcmd" type="text" placeholder="e.g. aider --message &quot;{prompt}&quot; --yes · code {dir} · leave blank to just write the file">
256
256
  <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>
257
257
  <div id="agentpresets" style="margin-top:8px"></div>
258
+ <div class="note muted" id="agentconnectors"></div>
258
259
  <div id="grantbox" style="margin-top:10px">
259
260
  <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>
260
261
  <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>
@@ -402,7 +403,7 @@ if(r.error==='not-connected'){el.innerHTML="<h4>AI review &middot; <span class='
402
403
  var body=r.text||'(no output)';reviewCache[path]={text:body,verdict:r.verdict||"",ideas:r.ideas||[],footer:r.footer};el.innerHTML=reviewHtml(name,body,r.verdict||"",r.ideas||[],tasks,r.footer);wireIdeas();});}
403
404
  var ALLTASKS=[];var PENDTASKS=[];var TFILTER={type:'',repo:''};var TARCH=false;
404
405
  var TORDER=['Fixes','Tests & CI','Security','Performance','Refactor','UI/UX','Docs','Features & other'];
405
- function taskRow(t,arch){return "<div class='task"+(t.done?" done":"")+"' data-id='"+esc(t.id)+"'>"+(arch?"":"<input type='checkbox' class='taskchk' title='mark done (auto-archives on sync)'"+(t.done?" checked":"")+">")+"<span class='t'>"+esc(t.text)+"</span>"+(t.repo?"<span class='rp'>"+esc(t.repo)+"</span>":"")+askBtn(t)+(arch?"<button class='rm restore' title='restore to active'>&#8630;</button>":"<button class='rm' title='remove'>&times;</button>")+"</div>";}
406
+ function taskRow(t,arch){return "<div class='task"+(t.done?" done":"")+"' data-id='"+esc(t.id)+"'>"+(arch?"":"<input type='checkbox' class='taskchk' title='mark done (auto-archives on sync)'"+(t.done?" checked":"")+">")+"<span class='t'>"+esc(t.text)+"</span>"+(t.repo?"<span class='rp'>"+esc(t.repo)+"</span>":"")+(arch&&t.removedBy?"<span class='rp' title='An approved task "+(t.merged?"merged it into another":"dropped it")+". Restore it to keep it.'>"+(t.merged?"merged":"dropped")+"</span>":"")+askBtn(t)+(arch?"<button class='rm restore' title='restore to active'>&#8630;</button>":"<button class='rm' title='remove'>&times;</button>")+"</div>";}
406
407
  function wireTaskRows(el){el.querySelectorAll('.task').forEach(function(row){var id=row.getAttribute('data-id');
407
408
  var cb=row.querySelector('.taskchk');if(cb)cb.addEventListener('change',function(){api('/api/tasks/toggle',{id:id}).then(function(){row.classList.toggle('done');});});
408
409
  wireAsk(row,id);
@@ -588,13 +589,21 @@ if(r.empty){btn.disabled=false;o.innerHTML="<div class='muted' style='margin-top
588
589
  var n=(r.written||[]).length;var opens=[];var h="<div class='drift' style='margin-top:10px'><div class='dh'><span class='dot-c'></span><span class='dn'>Done &mdash; wrote "+n+" file"+(n===1?"":"s")+"</span></div>";
589
590
  if(n){h+="<ul>";r.written.forEach(function(w){h+="<li class='info'>&#10003; <b>"+esc(w.name)+"</b> <span class='ev'>"+esc(w.file)+" ("+w.count+" task"+(w.count===1?"":"s")+")</span>"+(w.held?" <span class='ev'>&#9208; held: an agent is still running there, so these land when it finishes</span>":"")+"</li>";});h+="</ul>";}
590
591
  if(r.unresolved&&r.unresolved.length){var names=r.unresolved.map(function(u){return u.name;}).join(", ");var hasNoRepo=r.unresolved.some(function(u){return u.name==='(no repo)';});h+="<div class='dd' style='margin-top:6px'>&#9888; not sent: "+esc(names)+". "+(hasNoRepo?"Pick a repo in the dropdown next to <b>Add</b> so the task has somewhere to go.":"That repo isn't in the map &mdash; add its folder in Settings.")+"</div>";}
591
- if(r.handoff&&r.written&&r.written.length){opens=r.written.map(function(w){return api('/api/open',{path:w.path}).then(function(x){if(x&&x.busy){var d=document.createElement('div');d.className='dd';d.innerHTML="&#9888; <b>"+esc(w.name)+"</b> already has an agent running, so another wasn&#39;t started. "+(x.auto?"Its new tasks are held, and an agent starts on them when it finishes.":"Its new tasks are held until it finishes. After that, an agent starts on them the next time this tab checks the repo, or send again.");o.appendChild(d);}}).catch(function(){});});h+="<div class='dd' style='margin-top:8px'>&#129302; Handed "+n+" repo(s) to your agent &mdash; opening the <b>Agents</b> tab to watch it work&hellip;</div>";setTimeout(function(){setTab('agents');},500);}
592
+ if(r.handoff&&r.written&&r.written.length){opens=r.written.map(function(w){return api('/api/open',{path:w.path}).then(function(x){if(x&&x.blocked){var d=document.createElement('div');d.className='dd';d.innerHTML="&#9208; <b>"+esc(w.name)+"</b>: "+esc(x.note)+" <button class='ghost' style='padding:2px 8px;font-size:12px'>Start it anyway</button>";d.querySelector('button').addEventListener('click',function(){this.disabled=true;api('/api/open',{path:w.path,force:true}).then(function(y){d.innerHTML=y&&y.opened?"&#10003; Started an agent in <b>"+esc(w.name)+"</b>.":"&#9888; <b>"+esc(w.name)+"</b>: "+(y&&y.busy?"an agent is already running there.":"it didn&#39;t start. Check the command in Settings.");});});o.appendChild(d);}if(x&&x.busy){var d=document.createElement('div');d.className='dd';d.innerHTML="&#9888; <b>"+esc(w.name)+"</b> already has an agent running, so another wasn&#39;t started. "+(x.auto?"Its new tasks are held, and an agent starts on them when it finishes.":"Its new tasks are held until it finishes. After that, an agent starts on them the next time this tab checks the repo, or send again.");o.appendChild(d);}}).catch(function(){});});h+="<div class='dd' style='margin-top:8px'>&#129302; Handed "+n+" repo(s) to your agent &mdash; opening the <b>Agents</b> tab to watch it work&hellip;</div>";setTimeout(function(){setTab('agents');},500);}
592
593
  else{h+="<div class='dd' style='margin-top:8px'>Set an <b>agent command</b> in Settings to auto-run it on send (and watch it in the Agents tab). For now, tell your agent: <b>“Read .symbiot/TASKS.md and implement the unchecked items.”</b></div>";}
593
594
  h+="</div>";
594
595
  o.innerHTML=h;
595
596
  // re-enable only once every handoff has started (or been refused), so a double click can't send twice
596
597
  Promise.all(opens).then(function(){btn.disabled=false;});}).catch(function(e){btn.disabled=false;o.innerHTML="<div class='err' style='margin-top:10px'>Couldn&#39;t write tasks: "+esc(String((e&&e.message)||e))+"</div>";});}
598
+ // Connectors linked to Claude (claude.ai's Drive, Gmail…) reach Claude runs only: say which, or that this command doesn't get them.
599
+ var CONNECTORS=[];
600
+ function renderConnectors(){var el=document.getElementById('agentconnectors');if(!CONNECTORS.length){el.innerHTML='';return;}
601
+ var nm=function(c){return "<b>"+esc(c.name.replace(/^claude\\.ai\\s+/i,''))+"</b>";},ready=CONNECTORS.filter(function(c){return c.ready;}),wait=CONNECTORS.filter(function(c){return !c.ready;});
602
+ var cmd=document.getElementById('agentcmd').value;if(/orca-ide/.test(cmd)&&/claude/.test(cmd)){el.innerHTML="&#128268; Claude in Orca&#39;s tab asks you before it uses your connectors ("+CONNECTORS.map(nm).join(", ")+").";return;}
603
+ if(!/^\\s*claude\\b/.test(cmd)){el.innerHTML="&#128268; Your Claude connectors ("+CONNECTORS.map(nm).join(", ")+") reach Claude Code runs only. This command isn&#39;t Claude, so its runs can&#39;t use them.";return;}
604
+ el.innerHTML=(ready.length?"&#128268; Your agent&#39;s runs can use "+ready.map(nm).join(", ")+": linked to Claude, so each run allows their tools.":"")+(wait.length?(ready.length?" ":"&#128268; ")+wait.map(nm).join(", ")+(wait.length===1?" needs":" need")+" authorizing in your claude.ai connector settings first.":"");}
597
605
  function loadAgentCfg(){api('/api/agentcfg').then(function(d){document.getElementById('agentcmd').value=d.cmd||'';
606
+ CONNECTORS=(d.connectors&&d.connectors.list)||[];renderConnectors();
598
607
  var chips=[];(d.agents||[]).forEach(function(a){chips.push(a);});(d.editors||[]).forEach(function(e){chips.push(e);});
599
608
  var box=document.getElementById('agentpresets');
600
609
  if(!chips.length){box.innerHTML="<span class='muted' style='font-size:12px'>Nothing detected on PATH &mdash; type your own command above.</span>";return;}
@@ -604,7 +613,7 @@ var h="<span class='muted' style='font-size:12px'>Detected &mdash; click to use:
604
613
  if(chips.length>na)h+="<div class='muted' style='font-size:12px;margin-top:8px'>Editors &mdash; opens only, no review:</div>"+chips.slice(na).map(function(c,i){return chip(c,na+i);}).join("");
605
614
  box.innerHTML=h;
606
615
  box.querySelectorAll('.preset').forEach(function(btn){btn.addEventListener('click',function(){document.getElementById('agentcmd').value=chips[+btn.getAttribute('data-i')].tmpl;saveAgent();});});});}
607
- function saveAgent(){api('/api/agentcmd',{cmd:document.getElementById('agentcmd').value});}
616
+ function saveAgent(){renderConnectors();api('/api/agentcmd',{cmd:document.getElementById('agentcmd').value});}
608
617
  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;});}
609
618
  function loadScanRoots(){api('/api/scanroots').then(function(d){var box=document.getElementById('scanroots');var roots=d.effective||[];
610
619
  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("");
package/watch.mjs CHANGED
@@ -358,7 +358,7 @@ function draftReply(id, { run = runHandoff } = {}) {
358
358
  writeFileSync(join(dir, ".symbiot", "TASKS.md"), chat ? chatBrief(n, w) : draftBrief(n, w));
359
359
  writeFileSync(join(dir, ".symbiot", "handoff.json"), JSON.stringify({ name: ("Draft: " + n.text).slice(0, 60), env: { SYMBIOT_DRAFT: "1" } }));
360
360
  } catch (e) { return { error: "Couldn't write the brief: " + ((e && e.message) || e) }; }
361
- const e = run(dir);
361
+ const e = run(dir, { force: true }); // a click on Draft a reply asks for a run, even after one stopped on a question
362
362
  if (!e) return { error: "Your agent didn't start. Check its command in Settings → Handoff." };
363
363
  if (e.busy) return { error: "Your agent is still drafting this one. It's in the Agents tab." };
364
364
  const d2 = loadWatch(), n2 = d2.news.find((x) => x.id === id); if (n2) { n2.drafted = Date.now(); saveWatch(d2); }