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 +32 -0
- package/agents.mjs +84 -17
- package/core.mjs +38 -1
- package/index.mjs +5 -3
- package/package.json +1 -1
- package/server.mjs +3 -3
- package/tasks.mjs +37 -5
- package/ui.mjs +12 -3
- package/watch.mjs +1 -1
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 (
|
|
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
|
-
|
|
78
|
-
|
|
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
|
|
85
|
-
const
|
|
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]
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 "{prompt}" --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 — 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 · <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'>↶</button>":"<button class='rm' title='remove'>×</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'>↶</button>":"<button class='rm' title='remove'>×</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 — wrote "+n+" file"+(n===1?"":"s")+"</span></div>";
|
|
589
590
|
if(n){h+="<ul>";r.written.forEach(function(w){h+="<li class='info'>✓ <b>"+esc(w.name)+"</b> <span class='ev'>"+esc(w.file)+" ("+w.count+" task"+(w.count===1?"":"s")+")</span>"+(w.held?" <span class='ev'>⏸ 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'>⚠ 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 — 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="⚠ <b>"+esc(w.name)+"</b> already has an agent running, so another wasn'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'>🤖 Handed "+n+" repo(s) to your agent — opening the <b>Agents</b> tab to watch it work…</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="⏸ <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?"✓ Started an agent in <b>"+esc(w.name)+"</b>.":"⚠ <b>"+esc(w.name)+"</b>: "+(y&&y.busy?"an agent is already running there.":"it didn't start. Check the command in Settings.");});});o.appendChild(d);}if(x&&x.busy){var d=document.createElement('div');d.className='dd';d.innerHTML="⚠ <b>"+esc(w.name)+"</b> already has an agent running, so another wasn'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'>🤖 Handed "+n+" repo(s) to your agent — opening the <b>Agents</b> tab to watch it work…</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'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="🔌 Claude in Orca's tab asks you before it uses your connectors ("+CONNECTORS.map(nm).join(", ")+").";return;}
|
|
603
|
+
if(!/^\\s*claude\\b/.test(cmd)){el.innerHTML="🔌 Your Claude connectors ("+CONNECTORS.map(nm).join(", ")+") reach Claude Code runs only. This command isn't Claude, so its runs can't use them.";return;}
|
|
604
|
+
el.innerHTML=(ready.length?"🔌 Your agent's runs can use "+ready.map(nm).join(", ")+": linked to Claude, so each run allows their tools.":"")+(wait.length?(ready.length?" ":"🔌 ")+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 — type your own command above.</span>";return;}
|
|
@@ -604,7 +613,7 @@ var h="<span class='muted' style='font-size:12px'>Detected — click to use:
|
|
|
604
613
|
if(chips.length>na)h+="<div class='muted' style='font-size:12px;margin-top:8px'>Editors — 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'>×</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); }
|