symbiot 0.43.1 โ 0.44.0
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 +23 -0
- package/handover.mjs +38 -0
- package/lanes.mjs +142 -0
- package/mind.mjs +10 -4
- package/package.json +4 -2
- package/server.mjs +4 -0
- package/tasks.mjs +4 -2
- package/ui.mjs +7 -1
package/README.md
CHANGED
|
@@ -508,6 +508,29 @@ can't do.
|
|
|
508
508
|
app, so context carries from page to page without re-sending everything.
|
|
509
509
|
Settings โ **What Symbiot remembers** lists it, and forgets any of it.
|
|
510
510
|
|
|
511
|
+
### Lanes: agents hand work to each other
|
|
512
|
+
|
|
513
|
+
Each agent has its own lane: a repo, or **ops** for everything outside one (this
|
|
514
|
+
computer, accounts, services, your connectors). When an agent needs something
|
|
515
|
+
that's another lane's job, it doesn't stop and it doesn't ask you to do it. It
|
|
516
|
+
writes it to its `.symbiot/HANDOFF.md`, under the lane's name, and Symbiot:
|
|
517
|
+
|
|
518
|
+
1. starts that lane's agent on it: a repo gets it as a task, the way **Send to
|
|
519
|
+
repos** does, and ops gets a run of its own;
|
|
520
|
+
2. when that agent is done, puts what it did into the asking agent's
|
|
521
|
+
`.symbiot/ANSWERS.md` and starts that agent again, so it carries on.
|
|
522
|
+
|
|
523
|
+
The Agents tab lists the **Handovers**: who handed what to whom, and where it
|
|
524
|
+
stands. A handover to a lane that doesn't exist, or back to the agent's own lane,
|
|
525
|
+
goes back to the agent, not to you. After 4 handovers in a row, the next one isn't
|
|
526
|
+
started, so two lanes can't pass the same job back and forth.
|
|
527
|
+
|
|
528
|
+
What still reaches you is only what no agent can do: your body (a phone in your
|
|
529
|
+
hand, a cable, which network you're on), your identity or secrets (signing in, a
|
|
530
|
+
2FA code, a token from a provider's console) or a decision that's yours (closing an
|
|
531
|
+
account, spending money, sending something in your name). Every brief tells the
|
|
532
|
+
agent so, and such a question says which: `๐ค You (only you: your Meta token): โฆ`.
|
|
533
|
+
|
|
511
534
|
## Connect it (once)
|
|
512
535
|
|
|
513
536
|
Pick the AI you want it to write with:
|
package/handover.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// symbiot โ Handover: what every brief says about lanes, and about the steps
|
|
2
|
+
// only the user can do. Each agent works in its own lane (a repo, or ops for
|
|
3
|
+
// everything outside one), and hands what's another lane's job to that lane
|
|
4
|
+
// instead of stopping or asking the user (lanes.mjs runs it). What reaches the
|
|
5
|
+
// user is only what no agent can do, and the brief makes the agent say which.
|
|
6
|
+
// Text and parsing only, no imports: shared by every brief (tasks.mjs
|
|
7
|
+
// buildTasksMd, mind.mjs actBrief) and by lanes.mjs.
|
|
8
|
+
|
|
9
|
+
const OPS = "ops";
|
|
10
|
+
const MAX_LANES_LISTED = 40;
|
|
11
|
+
|
|
12
|
+
// The lines a brief gets about lanes. here: this agent's lane; lanes: the others
|
|
13
|
+
// it can hand to (repo folder names).
|
|
14
|
+
function handoverRules(lanes = [], here = "") {
|
|
15
|
+
const others = [...new Set(lanes.filter((l) => l && l !== here))].slice(0, MAX_LANES_LISTED);
|
|
16
|
+
return ["## Hand over what's another lane's job",
|
|
17
|
+
`Each agent has its own lane${here ? `: this one is \`${here}\`` : ""}. When something you need is another lane's job, don't stop, and don't ask the user to do it: hand it over. Write \`.symbiot/HANDOFF.md\` with a \`### \` heading naming the lane, then what's needed, self-contained (names, paths, links, ids) and why. Symbiot starts that lane's agent on it, and its result comes back to you in \`.symbiot/ANSWERS.md\`. Carry on with what doesn't depend on it, and don't tick what does.`,
|
|
18
|
+
`- \`${OPS}\`: anything outside a repo: this computer (find or install a tool, a JDK, an SDK), accounts and services (cloud, GitHub, DNS, email), the user's connectors.`,
|
|
19
|
+
`- A repo, by its folder name, for work that belongs in that project${others.length ? `: ${others.map((l) => `\`${l}\``).join(", ")}` : ""}.`,
|
|
20
|
+
""];
|
|
21
|
+
}
|
|
22
|
+
// The rule for options in QUESTIONS.md: the user gets only what no agent can do,
|
|
23
|
+
// and the option says which kind it is.
|
|
24
|
+
const ONLY_YOU = "- Only what no agent can do is the user's: their body (a phone in their hand, a cable, which network they're on), their identity or secrets (signing in, a 2FA code, a token from a provider's console) or a decision that's theirs (closing an account, spending money, sending something in their name). Start such an option with `๐ค You (only you: <which>):`, e.g. `๐ค You (only you: your Meta token): paste it into .env`. Anything an agent could do, yours or another lane's, is never a ๐ค step: do it, or hand it over. An option where picking it is enough starts with `๐ค Agent:`. Tag actions only: an option that just reports what the user saw or decides (`it notified me`, `not tried yet`) gets no tag.";
|
|
25
|
+
|
|
26
|
+
// What an agent handed over: [{ lane, text }] from its .symbiot/HANDOFF.md.
|
|
27
|
+
function parseHandoffs(md) {
|
|
28
|
+
const out = []; let cur = null;
|
|
29
|
+
for (const raw of String(md || "").split(/\r?\n/)) {
|
|
30
|
+
const m = raw.match(/^###\s+`?([^`\s]+)`?\s*$/);
|
|
31
|
+
if (m) { cur = { lane: m[1].trim(), text: "" }; out.push(cur); continue; }
|
|
32
|
+
if (/^#{1,2}\s/.test(raw)) { cur = null; continue; }
|
|
33
|
+
if (cur) cur.text += raw + "\n";
|
|
34
|
+
}
|
|
35
|
+
return out.map((h) => ({ lane: h.lane, text: h.text.trim().slice(0, 4000) })).filter((h) => h.lane && h.text);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export { OPS, handoverRules, ONLY_YOU, parseHandoffs };
|
package/lanes.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// symbiot โ Lanes: agents hand work to each other, so it keeps moving without
|
|
2
|
+
// you. Each agent has its own lane: a repo, or ops (everything outside one: this
|
|
3
|
+
// computer, accounts, services, connectors; a run of its own, mind.mjs actNow).
|
|
4
|
+
// When one needs another lane's job done, it writes it to its .symbiot/HANDOFF.md
|
|
5
|
+
// (handover.mjs: a "### <lane>" heading, then what's needed). Every 20 seconds
|
|
6
|
+
// the app (lanesTick) starts that lane's agent on it: a repo lane gets it as a
|
|
7
|
+
// task and a run there, the way Send to repos does; ops gets a run of its own.
|
|
8
|
+
// When the agent that took it over is done, what it did goes back to the agent
|
|
9
|
+
// that asked, in its .symbiot/ANSWERS.md, and that agent is started again to
|
|
10
|
+
// carry on. You only see what no agent can do, as a question in the Agents tab.
|
|
11
|
+
//
|
|
12
|
+
// The ledger, ~/.config/symbiot/lanes.json: { handoffs: [{ id, key, from: { lane,
|
|
13
|
+
// path }, to: { lane, path }, text, at, chain, status, job?, task?, error?,
|
|
14
|
+
// result?, reportedAt? }] }. status: started, held (that lane's agent was busy:
|
|
15
|
+
// it starts when it's free), done (reported back) or error (reported back too).
|
|
16
|
+
// chain: how many handovers in a row led here; past MAX_CHAIN it stops, so two
|
|
17
|
+
// lanes can't hand the same thing back and forth forever.
|
|
18
|
+
import { join, basename } from "node:path";
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, chmodSync } from "node:fs";
|
|
20
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
21
|
+
import { CONFIG_DIR, loadTasks } from "./core.mjs";
|
|
22
|
+
import { runHandoff, runningHandoff } from "./agents.mjs";
|
|
23
|
+
import { addTask, pushTasks } from "./tasks.mjs";
|
|
24
|
+
import { actNow } from "./mind.mjs";
|
|
25
|
+
import { repoPathMap } from "./scan.mjs";
|
|
26
|
+
import { OPS, parseHandoffs } from "./handover.mjs";
|
|
27
|
+
|
|
28
|
+
const LEDGER = join(CONFIG_DIR, "lanes.json");
|
|
29
|
+
const ACT_DIR = join(CONFIG_DIR, "drafts");
|
|
30
|
+
const MAX_KEEP = 200, MAX_CHAIN = 4, TAIL = 1500;
|
|
31
|
+
|
|
32
|
+
function loadLedger() { try { const d = JSON.parse(readFileSync(LEDGER, "utf8")); return { handoffs: Array.isArray(d.handoffs) ? d.handoffs : [] }; } catch { return { handoffs: [] }; } }
|
|
33
|
+
// It can name accounts and what's in them: yours only (0600).
|
|
34
|
+
function saveLedger(d) { try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(LEDGER, JSON.stringify({ handoffs: d.handoffs.slice(-MAX_KEEP) }, null, 1), { mode: 0o600 }); try { chmodSync(LEDGER, 0o600); } catch {} return true; } catch { return false; } }
|
|
35
|
+
const readSym = (path, f) => { try { return readFileSync(join(path, ".symbiot", f), "utf8"); } catch { return ""; } };
|
|
36
|
+
const firstLine = (t) => String(t || "").split("\n").find((l) => l.trim()) || "";
|
|
37
|
+
|
|
38
|
+
// A folder's lane: the repo it is, or ops for a run of its own.
|
|
39
|
+
function laneOf(path, map) {
|
|
40
|
+
if (path.startsWith(ACT_DIR)) return OPS;
|
|
41
|
+
const hit = Object.entries(map).find(([, p]) => p === path);
|
|
42
|
+
return hit ? hit[0] : basename(path);
|
|
43
|
+
}
|
|
44
|
+
// The lane a handover names, as a folder: a repo (any case), or ops. null if none.
|
|
45
|
+
function laneTarget(lane, map) {
|
|
46
|
+
if (lane.toLowerCase() === OPS) return { lane: OPS, path: "" };
|
|
47
|
+
const name = Object.keys(map).find((n) => n.toLowerCase() === lane.toLowerCase());
|
|
48
|
+
return name ? { lane: name, path: map[name] } : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Pick up what a folder's agent handed over and start the lanes it named.
|
|
52
|
+
// Each handover is started once (its key: where from, which lane, what).
|
|
53
|
+
function dispatch(path, { map = repoPathMap(), act = actNow, run = runHandoff, add = addTask, push = pushTasks, now = Date.now(), ledger = loadLedger() } = {}) {
|
|
54
|
+
const asked = parseHandoffs(readSym(path, "HANDOFF.md")); if (!asked.length) return [];
|
|
55
|
+
const from = { lane: laneOf(path, map), path }, parent = ledger.handoffs.filter((h) => h.to.path === path).pop(), chain = (parent ? parent.chain : 0) + 1;
|
|
56
|
+
const started = [];
|
|
57
|
+
for (const h of asked) {
|
|
58
|
+
const key = createHash("sha1").update(path + "\n" + h.lane.toLowerCase() + "\n" + h.text).digest("hex").slice(0, 16);
|
|
59
|
+
if (ledger.handoffs.some((x) => x.key === key)) continue;
|
|
60
|
+
const e = { id: randomBytes(4).toString("hex"), key, from, to: { lane: h.lane, path: "" }, text: h.text, at: now, chain, status: "started" };
|
|
61
|
+
const to = laneTarget(h.lane, map);
|
|
62
|
+
if (chain > MAX_CHAIN) Object.assign(e, { status: "error", error: `${MAX_CHAIN} handovers in a row led here, so this one wasn't started. Do it yourself, or ask the user.` });
|
|
63
|
+
else if (!to) Object.assign(e, { status: "error", error: `There's no lane called ${h.lane}. Lanes: ops, or a repo: ${Object.keys(map).slice(0, 30).join(", ")}.` });
|
|
64
|
+
else if (to.lane.toLowerCase() === from.lane.toLowerCase()) Object.assign(e, { status: "error", error: `${to.lane} is your own lane: do it yourself.` });
|
|
65
|
+
else if (to.lane === OPS) {
|
|
66
|
+
const r = act(h.text, { title: `handed over by ${from.lane}`, context: `${from.lane}'s agent (in ${path}) handed this over, and carries on once it's done.`, now });
|
|
67
|
+
if (r.error) Object.assign(e, { status: "error", error: r.error });
|
|
68
|
+
else Object.assign(e, { to: { lane: OPS, path: r.dir }, job: r.job });
|
|
69
|
+
} else {
|
|
70
|
+
const t = add(`${h.text.replace(/\s+/g, " ").slice(0, 900)} (handed over by ${from.lane})`, to.lane);
|
|
71
|
+
if (t.error) Object.assign(e, { status: "error", error: t.error });
|
|
72
|
+
else {
|
|
73
|
+
push({ repo: to.lane });
|
|
74
|
+
const r = run(to.path) || {};
|
|
75
|
+
Object.assign(e, { to, task: t.id, status: r.id && !r.busy && !r.blocked ? "started" : "held", ...(r.id ? { job: r.id } : {}) });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
ledger.handoffs.push(e); started.push(e);
|
|
79
|
+
}
|
|
80
|
+
return started;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// What an agent said last: its log after the newest run's header.
|
|
84
|
+
function lastWords(path) {
|
|
85
|
+
const log = readSym(path, "agent.log"), at = log.lastIndexOf("\n=== "); if (at < 0) return "";
|
|
86
|
+
const run = log.slice(at).split("\n").slice(3).join("\n").trim(); // past the header and the command
|
|
87
|
+
return run.length > TAIL ? "โฆ" + run.slice(-TAIL) : run;
|
|
88
|
+
}
|
|
89
|
+
// When the newest run in a folder started (its log header), or 0.
|
|
90
|
+
function lastRunStart(path) { const log = readSym(path, "agent.log"), m = [...log.matchAll(/^=== .* (\d{4}-\d\d-\d\dT[\d:.]+Z) ===$/gm)].pop(); return m ? Date.parse(m[1]) || 0 : 0; }
|
|
91
|
+
const openQuestions = (path) => /^###\s+/m.test(readSym(path, "QUESTIONS.md").split(/^##\s+Suggestions/m)[0]);
|
|
92
|
+
|
|
93
|
+
// Is it done? Ops: its run ended (ticked or not). A repo: the task it was given
|
|
94
|
+
// is ticked or its run ended, and no agent is working there. Waiting on the
|
|
95
|
+
// user's answer to a question there isn't done: that's in the Agents tab.
|
|
96
|
+
function outcome(e, { tasks = loadTasks(), running = runningHandoff } = {}) {
|
|
97
|
+
if (e.status === "error") return { text: `Couldn't hand it over: ${e.error}` };
|
|
98
|
+
const p = e.to.path; if (!p || running(p)) return null;
|
|
99
|
+
if (lastRunStart(p) < e.at) return null; // its run hasn't started yet (held while that lane was busy)
|
|
100
|
+
if (openQuestions(p) && !/^- \[x\]/im.test(readSym(p, "TASKS.md"))) return null; // waiting on the user, in that lane
|
|
101
|
+
const words = lastWords(p);
|
|
102
|
+
if (e.to.lane === OPS) {
|
|
103
|
+
const ticked = /^- \[x\]/im.test(readSym(p, "TASKS.md"));
|
|
104
|
+
return { text: `${ticked ? "Done" : "It stopped without finishing"} (the ops agent).${words ? `\n\nWhat it said:\n${words}` : ""}` };
|
|
105
|
+
}
|
|
106
|
+
const t = tasks.find((x) => x.id === e.task);
|
|
107
|
+
const done = t && (t.review || t.done || t.archived);
|
|
108
|
+
return { text: `${done ? `Done in ${e.to.lane}: the task is ticked, and its changes wait for the user's review in Symbiot` : `${e.to.lane}'s agent stopped without ticking it`}.${words ? `\n\nWhat it said:\n${words}` : ""}` };
|
|
109
|
+
}
|
|
110
|
+
// Tell the agent that asked: the result as an answer in its ANSWERS.md, then
|
|
111
|
+
// start it again to carry on (unless it's still running: it reads it next time).
|
|
112
|
+
function report(e, o, { run = runHandoff, running = runningHandoff, now = Date.now() } = {}) {
|
|
113
|
+
const p = e.from.path, file = join(p, ".symbiot", "ANSWERS.md");
|
|
114
|
+
try {
|
|
115
|
+
mkdirSync(join(p, ".symbiot"), { recursive: true });
|
|
116
|
+
const had = existsSync(file) ? readFileSync(file, "utf8") : "# Answers\nAnswers to your questions, and what other lanes did with what you handed over, newest last.\n";
|
|
117
|
+
writeFileSync(file, `${had.replace(/\s*$/, "")}\n\n### Handed over to ${e.to.lane}: ${firstLine(e.text).slice(0, 120)}\n${o.text}\n_answered by symbiot (handover) ${new Date(now).toISOString().slice(0, 10)}_\n`);
|
|
118
|
+
} catch { return false; }
|
|
119
|
+
if (!running(p)) run(p, { force: true });
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// One pass: start what's been handed over, from every lane, then report back
|
|
124
|
+
// what's done. The app runs it every 20 seconds.
|
|
125
|
+
function lanesTick({ map = repoPathMap(), act = actNow, run = runHandoff, running = runningHandoff, add = addTask, push = pushTasks, tasks, now = Date.now() } = {}) {
|
|
126
|
+
const ledger = loadLedger();
|
|
127
|
+
let acts = []; try { acts = readdirSync(ACT_DIR).filter((d) => d.startsWith("act-")).map((d) => join(ACT_DIR, d)); } catch {}
|
|
128
|
+
const started = [];
|
|
129
|
+
for (const path of [...Object.values(map), ...acts]) if (existsSync(join(path, ".symbiot", "HANDOFF.md"))) started.push(...dispatch(path, { map, act, run, add, push, now, ledger }));
|
|
130
|
+
const t = tasks || loadTasks(), reported = [];
|
|
131
|
+
for (const e of ledger.handoffs) {
|
|
132
|
+
if (e.reportedAt || running(e.from.path)) continue; // the one that asked reads it when it's started again, so not mid-run
|
|
133
|
+
const o = outcome(e, { tasks: t, running }); if (!o) continue;
|
|
134
|
+
if (report(e, o, { run, running, now })) { e.reportedAt = now; if (e.status !== "error") e.status = "done"; e.result = o.text.slice(0, 600); reported.push(e); }
|
|
135
|
+
}
|
|
136
|
+
if (started.length || reported.length) saveLedger(ledger);
|
|
137
|
+
return { started, reported };
|
|
138
|
+
}
|
|
139
|
+
// The Agents tab's list: newest first.
|
|
140
|
+
function lanesState() { return { handoffs: loadLedger().handoffs.slice(-30).reverse().map((e) => ({ id: e.id, from: e.from.lane, to: e.to.lane, text: firstLine(e.text).slice(0, 160), at: e.at, status: e.status, ...(e.error ? { error: e.error } : {}), ...(e.result ? { result: e.result } : {}) })) }; }
|
|
141
|
+
|
|
142
|
+
export { LEDGER, MAX_CHAIN, loadLedger, laneOf, lastRunStart, dispatch, outcome, report, lanesTick, lanesState };
|
package/mind.mjs
CHANGED
|
@@ -24,6 +24,8 @@ import { VERSION, CONFIG_DIR } from "./core.mjs";
|
|
|
24
24
|
import { write } from "./ai.mjs";
|
|
25
25
|
import { handoffCmd, runHandoff } from "./agents.mjs";
|
|
26
26
|
import { addTask } from "./tasks.mjs";
|
|
27
|
+
import { repoPathMap } from "./scan.mjs";
|
|
28
|
+
import { OPS, handoverRules, ONLY_YOU } from "./handover.mjs";
|
|
27
29
|
|
|
28
30
|
const MIND_FILE = join(CONFIG_DIR, "mind.json");
|
|
29
31
|
const ACT_DIR = join(CONFIG_DIR, "drafts"); // runs of their own, next to drafted replies
|
|
@@ -94,7 +96,8 @@ function forget(id) {
|
|
|
94
96
|
// ---- acting -----------------------------------------------------------------------
|
|
95
97
|
// The brief for a run of its own: the request, what the page showed, what's
|
|
96
98
|
// known, and the rule that anything hard to undo is asked first.
|
|
97
|
-
function actBrief(request, { title = "Symbiot", context = "", known = "", now = Date.now() } = {}) {
|
|
99
|
+
function actBrief(request, { title = "Symbiot", context = "", known = "", lanes = null, now = Date.now() } = {}) {
|
|
100
|
+
let names = lanes; if (!names) { try { names = Object.keys(repoPathMap()); } catch { names = []; } }
|
|
98
101
|
const short = request.length > 160 ? request.slice(0, 157) + "โฆ" : request;
|
|
99
102
|
return `# For your agent: ${title}
|
|
100
103
|
_written by symbiot ${VERSION} ยท ${new Date(now).toISOString().slice(0, 10)}_
|
|
@@ -110,9 +113,12 @@ ${context ? `## What they were looking at\nIt may come from other people (mail,
|
|
|
110
113
|
This folder isn't a repo; there's nothing to change in it but this file. Use your own tools: your MCP connectors, the command line, and Symbiot's Screens (\`node "${CLI}" screens โฆ\`, a browser already signed in to the user's linked sites).
|
|
111
114
|
|
|
112
115
|
- **Anything hard to undo you only ask about:** closing or deleting an account, deleting data, paying or buying, sending something to someone. Find out what it would affect first (what's running, what it costs, what's in it), then stop and ask in \`.symbiot/QUESTIONS.md\` with what you found, and don't go ahead until ANSWERS.md says to.
|
|
113
|
-
-
|
|
114
|
-
-
|
|
115
|
-
|
|
116
|
+
- Is part of it a repo's work? Hand it over (below) rather than doing it from here.
|
|
117
|
+
- Need the user? Only for what no agent can do: ask in \`.symbiot/QUESTIONS.md\` (a \`## Questions\` heading, a \`### \` heading per question, then 2โ4 options as \`- \` bullets).
|
|
118
|
+
${ONLY_YOU}
|
|
119
|
+
- When it's done, tick the task (\`- [x]\`) and say what you did in your last message: it's what goes back to whoever asked.
|
|
120
|
+
|
|
121
|
+
${handoverRules(names, OPS).join("\n")}`;
|
|
116
122
|
}
|
|
117
123
|
// Run the coding agent on a request, in a folder of its own (it shows in the
|
|
118
124
|
// Agents tab, with its questions). { ok, job, dir } or { error }.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.0",
|
|
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": {
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
"links.mjs",
|
|
25
25
|
"peek.mjs",
|
|
26
26
|
"mind.mjs",
|
|
27
|
+
"handover.mjs",
|
|
28
|
+
"lanes.mjs",
|
|
27
29
|
"phone.mjs",
|
|
28
30
|
"desktop.mjs",
|
|
29
31
|
"README.md",
|
|
@@ -60,7 +62,7 @@
|
|
|
60
62
|
"@anthropic-ai/sdk": "^0.128.0"
|
|
61
63
|
},
|
|
62
64
|
"scripts": {
|
|
63
|
-
"test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/peek.mjs && node test/mind.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
65
|
+
"test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/peek.mjs && node test/mind.mjs && node test/lanes.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
64
66
|
"lint": "eslint ."
|
|
65
67
|
},
|
|
66
68
|
"devDependencies": {
|
package/server.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAuto
|
|
|
22
22
|
import { watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
|
|
23
23
|
import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
|
|
24
24
|
import { mindState, forget } from "./mind.mjs";
|
|
25
|
+
import { lanesTick, lanesState } from "./lanes.mjs";
|
|
25
26
|
import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
|
|
26
27
|
|
|
27
28
|
// The in-app update installs the EXACT newest version (not the `latest` tag, which
|
|
@@ -220,6 +221,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
220
221
|
if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""))); }
|
|
221
222
|
if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
|
|
222
223
|
if (u.pathname === "/api/links/unlink" && req.method === "POST") { const b = await readBody(req); return json(res, unlinkSite(String(b.id || ""))); }
|
|
224
|
+
// Lanes (lanes.mjs): work agents handed to each other, and where it stands.
|
|
225
|
+
if (u.pathname === "/api/lanes") return json(res, lanesState());
|
|
223
226
|
// Watch (watch.mjs): a mapped page read again every few minutes, and what's new on it.
|
|
224
227
|
if (u.pathname === "/api/watch") return json(res, watchState());
|
|
225
228
|
if (u.pathname === "/api/watch/board") return json(res, watchBoard(Math.min(168, Math.max(1, Number(u.searchParams.get("hours")) || 24))));
|
|
@@ -297,6 +300,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
297
300
|
startWatches(); // pages you watch (Screens โ Watch), read every few minutes
|
|
298
301
|
startPhone(); // Watch on your phone: the computer listens if it's switched on, the phone asks if it's paired
|
|
299
302
|
setInterval(() => { try { startWaiting(); } catch {} }, 20000).unref(); // a run that waits for your step starts once the file it names changes
|
|
303
|
+
setInterval(() => { try { lanesTick(); } catch {} }, 20000).unref(); // agents hand work to other lanes, and hear back when it's done
|
|
300
304
|
}
|
|
301
305
|
|
|
302
306
|
export { updateCmd, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly };
|
package/tasks.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { join } from "node:path";
|
|
|
7
7
|
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
9
|
import { VERSION, LATEST_VERSION, REGISTRY, semverGt, loadConfig, saveConfig, loadTasks, saveTasks, taskWords, sameTask, uniqueTasks, sh, hasCmd, repoState } from "./core.mjs";
|
|
10
|
+
import { handoverRules, ONLY_YOU } from "./handover.mjs";
|
|
10
11
|
import { QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, handoffCmd, runningHandoff, writeTasks, startHeldTasks } from "./agents.mjs";
|
|
11
12
|
import { gitDefaultBranch } from "./drift.mjs";
|
|
12
13
|
import { repoPathMap, openWork, detectRepo } from "./scan.mjs";
|
|
@@ -435,10 +436,11 @@ function buildTasksMd(name, ctx, list) {
|
|
|
435
436
|
const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
|
|
436
437
|
for (const ty of keys) { L.push(`### ${ty}`); for (const t of byType[ty]) L.push(`- [ ] ${t.text}`); L.push(""); }
|
|
437
438
|
L.push("## When you finish an item", "- Tick it here (`- [x]`) as soon as it's done โ that's how it reaches review. Ticking doesn't archive it: the user approves it in Symbiot, which commits it on a branch and opens a PR.", "- Leave your changes **uncommitted**, and don't tick anything you didn't finish or couldn't verify.", "");
|
|
439
|
+
L.push(...handoverRules(ctx.lanes || [], name));
|
|
438
440
|
L.push("## If you need a decision, or have ideas", "You may be running unattended, so you can't ask in chat. Write `.symbiot/QUESTIONS.md` instead: Symbiot shows it to the user on your block in its Agents tab, and their answers come back in `.symbiot/ANSWERS.md` (read that first if it exists).",
|
|
439
441
|
`- At most ${QUESTIONS_MAX} questions, under a \`## Questions\` heading. Each is a \`### \` heading, then a line of context, then exactly ${OPTIONS_SHOWN} options as \`- \` bullets, the one you recommend first, marked \`(recommended)\`. Symbiot shows only the first ${OPTIONS_SHOWN}; the user can always answer in their own words.`,
|
|
440
442
|
"- Judge the options before you ask. Most people pick the recommended option without weighing the other, and Symbiot works for a whole company (developers, sales, everyone), not one person, so the choice is really yours. Both options must be good routes to the best solution, never filler or one you wouldn't take. Each says in plain words, with no jargon, what it does and what it changes from then on for the project, the people working on it and the company. Recommend the one that's best for, in this order, the company, the people doing the work, then the task's goal. Base that on evidence you can check here (git history, tests, logs, how it's used, the answers so far), not on what's quickest, and give that evidence in the context line in a sentence.",
|
|
441
|
-
|
|
443
|
+
ONLY_YOU,
|
|
442
444
|
"- A `๐ค You:` answer holds the next run back until the user's step is done. If their step changes a file, name the file in backticks (`.env`, `~/.termux/termux.properties`): the next run starts once it changes.",
|
|
443
445
|
"- If a step needs a release that isn't out yet, name its version in the question (\"Once 0.41.0 is installed: โฆ\"). Symbiot shows it next to the installed and npm versions, and holds back an answer that starts \"Done\" until that release is out and installed.",
|
|
444
446
|
`- Ideas, options or follow-ups outside these tasks go under \`## Suggestions\` as \`- \` bullets. The user can add them to their tasks in one click, and often adds every one, so judge them the same way: only ideas you'd recommend, best first, each saying what it changes and why it's worth doing. Symbiot shows them ${IDEAS_SHOWN} at a time.`,
|
|
@@ -472,7 +474,7 @@ function pushTasks(filter) {
|
|
|
472
474
|
if (st.behind) risk.push(`${st.behind} behind upstream on ${st.branch}`);
|
|
473
475
|
if (st.dirty && !st.stale) risk.push(`${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)`);
|
|
474
476
|
// held: an agent is still running there, so it lands when that one exits
|
|
475
|
-
const held = writeTasks(path, buildTasksMd(name, { branch: st.branch, commits, open, drift: risk, stack }, list));
|
|
477
|
+
const held = writeTasks(path, buildTasksMd(name, { branch: st.branch, commits, open, drift: risk, stack, lanes: Object.keys(byName) }, list));
|
|
476
478
|
written.push({ name, file: join(path, ".symbiot", "TASKS.md"), path, count: list.length, held });
|
|
477
479
|
} catch (e) { unresolved.push({ name, count: list.length, error: String((e && e.message) || e) }); }
|
|
478
480
|
}
|
package/ui.mjs
CHANGED
|
@@ -220,6 +220,7 @@ label.check input{width:auto}
|
|
|
220
220
|
<section id="panel-agents" class="hidden">
|
|
221
221
|
<div class="row"><span class="muted">Agents Symbiot has handed work to โ live status and output. Questions, options and ideas an agent leaves for you show up on its block, whichever model it runs.</span><button class="ghost" id="agentsrefresh" style="margin-left:auto">Refresh</button></div>
|
|
222
222
|
<div id="agentsmsg"></div>
|
|
223
|
+
<div id="laneslist"></div>
|
|
223
224
|
<div id="agentslist"></div>
|
|
224
225
|
</section>
|
|
225
226
|
<section id="panel-drift" class="hidden">
|
|
@@ -564,7 +565,12 @@ return "<div class='aq'><h4>⏸ Waiting on your step</h4><div class='qt'>
|
|
|
564
565
|
function wireWaits(el){el.querySelectorAll('.wstart').forEach(function(btn){btn.addEventListener('click',function(){var a=agentById(btn.getAttribute('data-id'));if(!a)return;btn.disabled=true;
|
|
565
566
|
api('/api/open',{path:a.path,force:true}).then(function(x){var msg=document.getElementById('agentsmsg');msg.innerHTML=x&&x.opened?"<div class='note ok'>✓ Started your agent in <b>"+esc(a.name)+"</b>.</div>":"<div class='note err'>"+esc(a.name)+": "+(x&&x.busy?"an agent is already running there.":"it didn't start. Check the command in Settings.")+"</div>";loadAgents();}).catch(function(e){btn.disabled=false;document.getElementById('agentsmsg').innerHTML="<div class='note err'>"+esc(String((e&&e.message)||e))+"</div>";});});});}
|
|
566
567
|
function answering(){var f=document.activeElement;return !!(f&&f.closest&&f.closest('.aq'));}
|
|
567
|
-
|
|
568
|
+
// Handovers (lanes.mjs): what one lane's agent handed to another, and where it stands.
|
|
569
|
+
var LANEWORD={started:'working on it',held:'queued: that lane is busy',done:'done, reported back',error:"couldn't hand over"};
|
|
570
|
+
function loadLanes(){api('/api/lanes').then(function(d){var el=document.getElementById('laneslist');if(!el||!d)return;var hs=d.handoffs||[];
|
|
571
|
+
el.innerHTML=hs.length?"<div class='tgroup'>Handovers <span class='tcount'>"+hs.length+"</span></div>"+hs.slice(0,12).map(function(h){
|
|
572
|
+
return "<div class='task' title='"+escQ(h.result||h.error||h.text)+"'><span class='t'><b>"+esc(h.from)+"</b> → <b>"+esc(h.to)+"</b>: "+esc(h.text)+"</span><span class='rp'"+(h.status==='error'?" style='color:var(--amber)'":"")+">"+esc(LANEWORD[h.status]||h.status)+"</span></div>";}).join(''):'';});}
|
|
573
|
+
function loadAgents(){loadLanes();api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
|
|
568
574
|
if(!list||!list.length){AGENTLIST=[];el.innerHTML="<div class='muted' style='margin-top:12px'>No agents yet. In <b>Tasks</b>, tick ideas and hit <b>Send to repos</b> (with an agent command set in Settings) — you'll watch it work here.</div>";stopAgentsPoll();return;}
|
|
569
575
|
var anyRunning=list.some(function(a){return a.status==='running';});
|
|
570
576
|
if(answering()){stopAgentsPoll();if(anyRunning&¤t==='agents')agentsTimer=setTimeout(loadAgents,2000);return;} // don't re-render under someone typing an answer
|