symbiot 0.43.1 → 0.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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/agents.mjs CHANGED
@@ -495,12 +495,29 @@ function suggestionTarget(s) {
495
495
  // The OPEN questions (not yet in ANSWERS.md) and the agent's ideas, each with
496
496
  // the repo whose tasks it goes to (this one unless it names another) and marked
497
497
  // if it's already on that repo's task list, in these words or nearly (sameTask).
498
+ // Ideas the user turned down (Skip) stay in .symbiot/SKIPPED.md, a file like
499
+ // ANSWERS.md, so they leave the list and the brief tells the next run not to
500
+ // suggest them again.
501
+ const SKIPPED = "SKIPPED.md";
502
+ const skippedIdeas = (path) => [...readSymbiot(path, SKIPPED).matchAll(/^-\s+(.+)$/gm)].map((m) => m[1].trim());
503
+ function skipIdea(path, text) {
504
+ path = String(path || ""); text = String(text || "").replace(/\s+/g, " ").trim().slice(0, 1000);
505
+ if (!path || !knownRun(path)) return { error: "No agent has run in that folder." };
506
+ if (!text) return { error: "No idea to skip." };
507
+ const f = join(path, ".symbiot", SKIPPED);
508
+ if (!skippedIdeas(path).some((s) => sameTask(s, text))) {
509
+ try { mkdirSync(dirname(f), { recursive: true }); writeFileSync(f, (existsSync(f) ? readFileSync(f, "utf8").replace(/\n*$/, "\n") : "# Ideas the user turned down\nDon't suggest these again.\n\n") + `- ${text}\n`); }
510
+ catch (e) { return { error: String((e && e.message) || e) }; }
511
+ }
512
+ return { ok: true };
513
+ }
498
514
  function agentQuestions(path, repo) {
499
515
  const p = parseQuestions(readSymbiot(path, "QUESTIONS.md"));
500
516
  const done = new Set([...readSymbiot(path, "ANSWERS.md").matchAll(/^###\s+(.+)$/gm)].map((m) => qKey(m[1])));
501
517
  const open = p.questions.filter((x) => !done.has(qKey(x.q)));
502
- const tasks = p.suggestions.length ? loadTasks() : [];
503
- const ideas = p.suggestions.map(suggestionTarget).map(({ repo: to, text }) => ({ text, repo: to || repo, other: !!to && to !== repo, added: tasks.some((t) => t.repo === (to || repo) && sameTask(t.text, text)) })).filter((s) => !s.added);
518
+ const tasks = p.suggestions.length ? loadTasks() : [], skipped = p.suggestions.length ? skippedIdeas(path) : [];
519
+ const ideas = p.suggestions.map(suggestionTarget).filter(({ text }) => !skipped.some((s) => sameTask(s, text)))
520
+ .map(({ repo: to, text }) => ({ text, repo: to || repo, other: !!to && to !== repo, added: tasks.some((t) => t.repo === (to || repo) && sameTask(t.text, text)) })).filter((s) => !s.added);
504
521
  return {
505
522
  questions: open.slice(0, QUESTIONS_MAX).map((x) => ({ ...x, options: x.options.slice(0, OPTIONS_SHOWN) })), answered: p.questions.length - open.length,
506
523
  suggestions: ideas, ideasShown: IDEAS_SHOWN,
@@ -557,4 +574,4 @@ function agentsList() {
557
574
  }).concat(earlierRuns());
558
575
  }
559
576
 
560
- export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, connectorsInfo, fillHandoff, runHandoff, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, agentQuestions, answerQuestions, agentsList };
577
+ export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, connectorsInfo, fillHandoff, runHandoff, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList };
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
@@ -23,7 +23,9 @@ import { randomBytes } from "node:crypto";
23
23
  import { VERSION, CONFIG_DIR } from "./core.mjs";
24
24
  import { write } from "./ai.mjs";
25
25
  import { handoffCmd, runHandoff } from "./agents.mjs";
26
- import { addTask } from "./tasks.mjs";
26
+ import { addTask, pushTasks } 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
- - Can't do it (no tool or connector for it, not signed in)? Say exactly what's missing in QUESTIONS.md, with options starting "👤 You:" or "🤖 Agent:".
114
- - When it's done, tick the task (\`- [x]\`) and say what you did in your last message.
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 }.
@@ -135,11 +141,39 @@ function actNow(request, { title, context, known, run = runHandoff, now = Date.n
135
141
 
136
142
  // ---- one voice --------------------------------------------------------------------
137
143
  const IDENTITY = `You are Symbiot, the one assistant in the user's Symbiot app. The Dashboard's cards, the Tasks list and the rest are only places they talk to you: it's all you, and you remember across them (see "What you know" and "Lately, elsewhere in the app").`;
138
- const RULES = `Reply with JSON only, no markdown fence:
144
+ // lanes: the repos "do" can point at; self: Symbiot's own repo among them, where
145
+ // a flaw in Symbiot itself is fixed.
146
+ function rulesFor(lanes = [], self = "") {
147
+ return `Reply with JSON only, no markdown fence:
139
148
  {"reply": "what you say, plain text, brief, no preamble",
140
- "do": null or {"agent": "what their coding agent should do now: self-contained, with names, links and ids"} or {"task": "a task for their list", "repo": "a repo name, or empty"},
149
+ "do": null or {"agent": "what a coding agent should do now: self-contained, with names, links and ids", "repo": "the lane it's for, or empty"} or {"task": "a task for later", "repo": "the lane it's for, or empty"},
141
150
  "remember": [{"name": "…", "kind": "person|account|site|repo|project|decision|preference", "fact": "…"}]}
142
- When they ask you to do something (look into it, find out, fix, set up, sign in, close, send, chase), do it, don't explain what you can't do: "agent" runs their coding agent now, with their tools and connectors (MCP, the command line, a browser signed in to their linked sites). Use "task" for what's for later, or belongs in a repo's work. Don't ask their permission to hand it over: the agent asks them first, in the Agents tab, before anything hard to undo (closing an account, deleting, paying, sending); say so in your reply when it applies, with what to check first. A question you can answer from what's here: answer it, "do": null. "remember": only lasting facts worth knowing on another page (who someone is, which account is what, a decision, how they like things); [] for anything else.`;
151
+ When they ask you to do something (look into it, find out, fix, set up, sign in, close, send, chase), do it, don't explain what you can't do: "agent" starts a coding agent now, with their tools and connectors (MCP, the command line, a browser signed in to their linked sites). With a "repo", it's that project's agent, working in it; empty, an agent for everything outside a repo (this computer, accounts, services). Use "task" for what's for later. Don't ask their permission to hand it over: the agent asks them first, in the Agents tab, before anything hard to undo (closing an account, deleting, paying, sending); say so in your reply when it applies, with what to check first. A question you can answer from what's here: answer it, "do": null.
152
+ Lanes ("repo" is one of these, exactly): ${lanes.length ? lanes.slice(0, 60).join(", ") : "(none found)"}.${self ? ` Symbiot itself is "${self}": a flaw in how Symbiot works (how it read a page, what a brief or a card said, anything in the app) goes there.` : ""}
153
+ Don't wait to be asked to fix Symbiot. When you notice it got something wrong (you misread a page, a brief or a card misled them, a step made them do an agent's job), say so plainly and, in the same reply, start the fix: "agent"${self ? ` with "repo": "${self}"` : ""}, saying what went wrong, an example, and what it should do instead.
154
+ "remember": only lasting facts worth knowing on another page (who someone is, which account is what, a decision, how they like things); [] for anything else.`;
155
+ }
156
+ // Symbiot's own repo among your lanes (its package.json is symbiot's), or "".
157
+ function selfLane(map) {
158
+ for (const [n, p] of Object.entries(map)) { try { if (JSON.parse(readFileSync(join(p, "package.json"), "utf8")).name === "symbiot") return n; } catch {} }
159
+ return "";
160
+ }
161
+ const laneName = (repo, map) => Object.keys(map).find((n) => n.toLowerCase() === String(repo || "").trim().toLowerCase()) || "";
162
+ // Do it, in the right lane: a repo's work joins its tasks and its agent starts
163
+ // there, the way Send to repos does; anything else, an agent of its own (actNow).
164
+ function actIn(request, repo, { map = {}, known = "", title, context, run = runHandoff, add = addTask, push = pushTasks, ops = actNow, now = Date.now() } = {}) {
165
+ const lane = laneName(repo, map);
166
+ if (!lane) return { ...ops(request, { title, context, known, now }), lane: "" };
167
+ const t = add(request, lane); if (t && t.error) return { error: t.error };
168
+ const p = push({ repo: lane }); if (!p || !p.written || !p.written.length) return { error: `Couldn't write ${lane}'s tasks for its agent.` };
169
+ const e = run(map[lane]); if (!e) return { error: "The agent didn't start. Check its command in Settings → Handoff." };
170
+ return { ok: true, lane, task: t.id, ...(e.busy || e.blocked ? { queued: true } : { job: e.id }) };
171
+ }
172
+ // For later: on the list, in a repo's lane if it names one Symbiot knows.
173
+ function taskIn(text, repo, { map = {}, add = addTask } = {}) {
174
+ const lane = laneName(repo, map), r = add(text, lane);
175
+ return r && r.error ? { error: r.error } : { ok: true, id: r.id, lane };
176
+ }
143
177
 
144
178
  function parseReply(raw) {
145
179
  const t = String(raw || "").trim().replace(/^```(?:json)?/i, "").replace(/```$/, "").trim();
@@ -152,9 +186,10 @@ function parseReply(raw) {
152
186
  // what this page is for; context: what the page shows; history: this chat's
153
187
  // own last turns. act: { agent(request) -> result, task(text, repo) -> result },
154
188
  // what "do" runs here. Gives { reply, did?, remembered }.
155
- async function converse({ where, role = "", context = "", history = "", question, act = {}, ask = write, now = Date.now() }) {
189
+ async function converse({ where, role = "", context = "", history = "", question, act = {}, ask = write, map = null, now = Date.now() }) {
156
190
  const d = loadMind(), known = recallText(recall(question, now, d)), elsewhere = lately(where, d);
157
- const system = `${IDENTITY} ${role}\n\n${RULES}`;
191
+ let lanes = map; if (!lanes) { try { lanes = repoPathMap(); } catch { lanes = {}; } }
192
+ const system = `${IDENTITY} ${role}\n\n${rulesFor(Object.keys(lanes), selfLane(lanes))}`;
158
193
  const prompt = (known ? `What you know (from across the app):\n${known}\n\n` : "") + (elsewhere ? `Lately, elsewhere in the app:\n${elsewhere}\n\n` : "") +
159
194
  (context ? context + "\n\n" : "") + (history ? `This chat so far:\n${history}\n\n` : "") + `They say (on ${where}): ${question}`;
160
195
  const raw = await ask(system, prompt);
@@ -162,12 +197,18 @@ async function converse({ where, role = "", context = "", history = "", question
162
197
  const j = parseReply(raw);
163
198
  let reply = String(j.reply || "").trim().slice(0, 4000) || "(no answer)", did = null;
164
199
  const want = j.do && typeof j.do === "object" ? j.do : null;
200
+ // say where it actually went, from what happened, not from what the model meant
201
+ const repo = want ? String(want.repo || "") : "";
165
202
  if (want && want.agent && act.agent) {
166
- did = { kind: "agent", request: String(want.agent).slice(0, 2000), ...(await act.agent(String(want.agent), known)) };
167
- reply += did.error ? `\n\n(I couldn't hand it to your agent: ${did.error})` : "\n\n→ Handed to your agent. It's in the Agents tab, and it asks you there before anything hard to undo.";
203
+ did = { kind: "agent", request: String(want.agent).slice(0, 2000), ...(await act.agent(String(want.agent), known, repo)) };
204
+ reply += did.error ? `\n\n(I couldn't hand it to an agent: ${did.error})`
205
+ : did.lane ? `\n\n→ Handed to ${did.lane}'s agent, as a task there${did.queued ? " (it starts once the run there now finishes)" : ""}. It's in the Agents tab.`
206
+ : "\n\n→ Handed to your agent. It's in the Agents tab, and it asks you there before anything hard to undo.";
168
207
  } else if (want && want.task && act.task) {
169
- did = { kind: "task", text: String(want.task).slice(0, 300), repo: String(want.repo || ""), ...(await act.task(String(want.task), String(want.repo || ""))) };
170
- reply += did.error ? `\n\n(I couldn't add the task: ${did.error})` : `\n\n→ Added to your tasks${did.repo ? ` for ${did.repo}` : ""}: ${did.text}`;
208
+ did = { kind: "task", text: String(want.task).slice(0, 300), ...(await act.task(String(want.task), repo)) };
209
+ reply += did.error ? `\n\n(I couldn't add the task: ${did.error})`
210
+ : did.lane ? `\n\n→ Added to ${did.lane}'s tasks: ${did.text}`
211
+ : `\n\n→ Added to your tasks, but not to a repo${repo ? ` (there's no lane called ${repo})` : ""}, so no agent will pick it up until it has one: ${did.text}`;
171
212
  }
172
213
  // read again: another chat may have written while the model answered
173
214
  const d2 = loadMind(), remembered = rememberIn(d2, j.remember, where, now);
@@ -175,7 +216,7 @@ async function converse({ where, role = "", context = "", history = "", question
175
216
  saveMind(d2);
176
217
  return { reply, ...(did ? { did } : {}), remembered };
177
218
  }
178
- // The usual "task" act: added to your list (a repo's, if named).
179
- const addToTasks = (text, repo) => { const r = addTask(text, repo); return r && r.error ? { error: r.error } : { ok: true, id: r.id }; };
219
+ // The usual "task" act, for a caller with no lanes of its own to pass.
220
+ const addToTasks = (text, repo) => { let map = {}; try { map = repoPathMap(); } catch {} return taskIn(text, repo, { map }); };
180
221
 
181
- export { MIND_FILE, loadMind, remember, recall, recallText, lately, mindState, forget, actBrief, actNow, parseReply, converse, addToTasks };
222
+ export { MIND_FILE, loadMind, remember, recall, recallText, lately, mindState, forget, actBrief, actNow, actIn, taskIn, selfLane, rulesFor, parseReply, converse, addToTasks };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.43.1",
3
+ "version": "0.44.1",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
@@ -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, connectorsInfo, answerQuestions, agentsList, startWaiting } from "./agents.mjs";
13
+ import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, skipIdea, agentsList, startWaiting } 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";
@@ -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
@@ -156,7 +157,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
156
157
  }
157
158
  if (u.pathname === "/api/pending/diff") { const p = repoPathMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(p) : "" }); }
158
159
  if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req); if (b.bump) await learnNpm([repoPathMap()[String(b.repo || "")]]); return json(res, approveRepo(String(b.repo || ""), { bump: b.bump })); }
159
- if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req); if (b.bump) await learnNpm([repoPathMap()[String(b.repo || "")]]); return json(res, approveChanges(String(b.repo || ""), { bump: b.bump })); }
160
+ if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req); if (b.bump) await learnNpm([repoPathMap()[String(b.repo || "")]]); return json(res, approveChanges(String(b.repo || ""), { bump: b.bump, tick: b.tick })); }
160
161
  if (u.pathname === "/api/pending/sendback" && req.method === "POST") { const b = await readBody(req); return json(res, sendBack(String(b.id || ""))); }
161
162
  if (u.pathname === "/api/automerge" && req.method === "POST") { const b = await readBody(req); return json(res, setAutoMerge(String(b.repo || ""), !!b.on)); }
162
163
  if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
@@ -177,6 +178,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
177
178
  }
178
179
  if (u.pathname === "/api/agents") return json(res, await withReleases(agentsList()));
179
180
  if (u.pathname === "/api/agents/answer" && req.method === "POST") { const b = await readBody(req); return json(res, answerQuestions(String(b.path || ""), b.answers, { rerun: !!b.rerun })); }
181
+ if (u.pathname === "/api/agents/skip" && req.method === "POST") { const b = await readBody(req); return json(res, skipIdea(String(b.path || ""), b.text)); }
180
182
  if (u.pathname === "/api/mail") return json(res, mailState());
181
183
  if (u.pathname === "/api/mail/set" && req.method === "POST") { const b = await readBody(req); return json(res, setMail(b)); }
182
184
  if (u.pathname === "/api/mail/preview") { const items = sentMail(Number(u.searchParams.get("days")) || since, true); return json(res, { count: items.length, items: items.slice(0, 20) }); }
@@ -220,6 +222,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
220
222
  if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""))); }
221
223
  if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
222
224
  if (u.pathname === "/api/links/unlink" && req.method === "POST") { const b = await readBody(req); return json(res, unlinkSite(String(b.id || ""))); }
225
+ // Lanes (lanes.mjs): work agents handed to each other, and where it stands.
226
+ if (u.pathname === "/api/lanes") return json(res, lanesState());
223
227
  // Watch (watch.mjs): a mapped page read again every few minutes, and what's new on it.
224
228
  if (u.pathname === "/api/watch") return json(res, watchState());
225
229
  if (u.pathname === "/api/watch/board") return json(res, watchBoard(Math.min(168, Math.max(1, Number(u.searchParams.get("hours")) || 24))));
@@ -297,6 +301,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
297
301
  startWatches(); // pages you watch (Screens → Watch), read every few minutes
298
302
  startPhone(); // Watch on your phone: the computer listens if it's switched on, the phone asks if it's paired
299
303
  setInterval(() => { try { startWaiting(); } catch {} }, 20000).unref(); // a run that waits for your step starts once the file it names changes
304
+ setInterval(() => { try { lanesTick(); } catch {} }, 20000).unref(); // agents hand work to other lanes, and hear back when it's done
300
305
  }
301
306
 
302
307
  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";
@@ -259,8 +260,37 @@ function shipWithBump(path, texts, opts) {
259
260
  if (r.error) { undo(); return r; }
260
261
  return { ...r, bumped: to };
261
262
  }
263
+ // What the last run in a folder said when it finished: agent.log after the last
264
+ // run's "=== name time ===" and "$ command" lines.
265
+ function runSummary(path) {
266
+ let log = ""; try { log = readFileSync(join(path, ".symbiot", "agent.log"), "utf8"); } catch { return ""; }
267
+ const at = log.lastIndexOf("\n=== "), rest = at < 0 ? log : log.slice(log.indexOf("\n", at + 1) + 1);
268
+ return rest.replace(/^\$ .*\n?/, "").trim().slice(-8000);
269
+ }
270
+ // Whether a run's summary says it finished a task: one of its lines (a bullet,
271
+ // a paragraph) has most of the task's words, and doesn't say it wasn't done. A
272
+ // guess the user confirms, so it leans to ticking: an unticked task only comes
273
+ // back as a task.
274
+ const NOT_DONE = /\b(didn['’]?t|did not|couldn['’]?t|could not|can['’]?t|cannot|wasn['’]?t|isn['’]?t|haven['’]?t|not (yet|done|finished|ticked|started)|still (open|to do|needs?)|left (it|them|alone)|blocked)\b/i;
275
+ const keyWords = (s) => new Set(taskWords(s).split(" ").filter((w) => w.length > 3).map((w) => w.replace(/s$/, "")));
276
+ function saidFinished(summary, text) {
277
+ const want = keyWords(text); if (want.size < 2) return false;
278
+ return String(summary || "").split(/\n/).some((l) => {
279
+ if (NOT_DONE.test(l)) return false;
280
+ const has = keyWords(l); let n = 0; for (const w of want) if (has.has(w)) n++;
281
+ return n >= Math.min(3, want.size) && n / want.size >= 0.4;
282
+ });
283
+ }
284
+ // The open tasks of a repo whose changes are approved without one: Approve
285
+ // offers to tick them, checked when the last run's summary says it finished them,
286
+ // so finished work doesn't go out again as new tasks on the next send.
287
+ function finishedOffer(path, open) {
288
+ const summary = runSummary(path);
289
+ return uniqueTasks(open.map((x) => x.text)).slice(0, 12).map((text) => { const x = open.find((y) => y.text === text); return { id: x.id, text, finished: saidFinished(summary, text) }; });
290
+ }
262
291
  // Repos with tasks awaiting review, plus repos Symbiot sent tasks to that have
263
- // uncommitted changes no ticked task covers (untasked: approve them as-is).
292
+ // uncommitted changes no ticked task covers (untasked: approve them as-is, with
293
+ // `open`, the repo's open tasks, to tick the ones the run finished).
264
294
  // running: an agent is still editing there, so its changes may be half done.
265
295
  function pendingReview() {
266
296
  const t = loadTasks(), by = {}; for (const x of t) if (x.review && !x.done && !x.archived) (by[x.repo] = by[x.repo] || []).push(x);
@@ -270,7 +300,9 @@ function pendingReview() {
270
300
  const out = Object.keys(by).sort().map((repo) => { const path = map[repo] || ""; return { repo, path, tasks: by[repo], autoMerge: am.includes(repo), running: !!(path && runningHandoff(path)), ...(path ? { ...workingChanges(path), unreleased: unreleased(path), bumpOffer: bumpOffer(path), publishesOnMerge: publishesOnMerge(path) } : { branch: "", files: [], stat: "" }) }; });
271
301
  for (const repo of sent.sort()) {
272
302
  const path = map[repo]; if (!path || !existsSync(join(path, ".symbiot", "TASKS.md"))) continue;
273
- const wc = workingChanges(path); if (wc.files.length) out.push({ repo, path, tasks: [], untasked: true, autoMerge: am.includes(repo), running: !!runningHandoff(path), ...wc, unreleased: unreleased(path), bumpOffer: bumpOffer(path), publishesOnMerge: publishesOnMerge(path) });
303
+ const wc = workingChanges(path); if (!wc.files.length) continue;
304
+ const open = finishedOffer(path, t.filter((x) => x.repo === repo && !x.done && !x.archived && !x.review));
305
+ out.push({ repo, path, tasks: [], untasked: true, open, autoMerge: am.includes(repo), running: !!runningHandoff(path), ...wc, unreleased: unreleased(path), bumpOffer: bumpOffer(path), publishesOnMerge: publishesOnMerge(path) });
274
306
  }
275
307
  return out;
276
308
  }
@@ -388,14 +420,24 @@ function approveRepo(repo, opts = {}) {
388
420
  }
389
421
  // "Approve changes without a task": ship the uncommitted changes even though no
390
422
  // ticked task is behind them (a fix the agent made but didn't tick). If tasks are
391
- // awaiting review in the repo, this is just Approve.
423
+ // awaiting review in the repo, this is just Approve. opts.tick: ids of the
424
+ // repo's open tasks the changes finished (the run didn't tick them): they're
425
+ // approved with the changes, as Approve does, so they don't go out again.
392
426
  function approveChanges(repo, opts = {}) {
393
427
  if (loadTasks().some((x) => x.repo === repo && x.review && !x.done && !x.archived)) return approveRepo(repo, opts);
394
428
  const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + (repo || "(no repo)") };
395
429
  const busy = stillWorking(repo, path); if (busy) return busy;
396
430
  if (!workingChanges(path).files.length) return { error: "No uncommitted changes in " + repo + "." };
397
- const r = shipWithBump(path, [], { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
398
- return r.error ? r : { ...r, approved: 0 };
431
+ const ids = new Set(Array.isArray(opts.tick) ? opts.tick.map(String) : []);
432
+ const ticked = (t) => t.filter((x) => ids.has(x.id) && x.repo === repo && !x.done && !x.archived && !x.review);
433
+ const r = shipWithBump(path, uniqueTasks(ticked(loadTasks()).map((x) => x.text)), { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
434
+ if (r.error) return r;
435
+ // read again: the ship took a while, and the list may have changed meanwhile
436
+ const t = loadTasks(), items = ticked(t), now = Date.now();
437
+ for (const x of items) { 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]; }
438
+ const removed = items.length ? applyRemovals(t) : 0;
439
+ if (items.length) saveTasks(t);
440
+ return { ...r, approved: items.length, ...(removed ? { removed } : {}) };
399
441
  }
400
442
  // Not right: reopen it and untick it in TASKS.md so the agent picks it up again.
401
443
  function sendBack(id) {
@@ -435,13 +477,14 @@ function buildTasksMd(name, ctx, list) {
435
477
  const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
436
478
  for (const ty of keys) { L.push(`### ${ty}`); for (const t of byType[ty]) L.push(`- [ ] ${t.text}`); L.push(""); }
437
479
  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.", "");
480
+ L.push(...handoverRules(ctx.lanes || [], name));
438
481
  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
482
  `- 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
483
  "- 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
- "- Start every option with who acts, because the user reads it as something they'd be agreeing to do: `👤 You:` when they have to do something (a setting, a click, a command), `🤖 Agent:` when picking it is enough and the next run does the work. If it takes both, put their step first: `👤 You: allow npm install in Settings. 🤖 Agent: the next run adds ESLint`. Tag actions only: an option that just reports what the user saw or decides (`it notified me`, `not tried yet`, `keep it as it is`) gets no tag.",
484
+ ONLY_YOU,
442
485
  "- 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
486
  "- 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
- `- 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.`,
487
+ `- 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. Ideas the user turned down are in \`.symbiot/SKIPPED.md\`: don't suggest them again.`,
445
488
  "- An idea for a different project than this one (Symbiot itself, say, the app that sent you this brief) starts with that project's folder name, `- [repo: symbiot] …`, so it goes to that project's tasks instead of this one's.",
446
489
  "- Carry on with everything that doesn't depend on an answer, and don't tick an item that does. Ask there rather than doing anything destructive.", "");
447
490
  L.push("---", 'To action these, tell your coding agent: "Read `.symbiot/TASKS.md` and implement the unchecked items in this repo, using the context above. Tick each item as you finish it and leave changes uncommitted for review. Confirm with me before anything destructive."');
@@ -472,11 +515,11 @@ function pushTasks(filter) {
472
515
  if (st.behind) risk.push(`${st.behind} behind upstream on ${st.branch}`);
473
516
  if (st.dirty && !st.stale) risk.push(`${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)`);
474
517
  // 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));
518
+ const held = writeTasks(path, buildTasksMd(name, { branch: st.branch, commits, open, drift: risk, stack, lanes: Object.keys(byName) }, list));
476
519
  written.push({ name, file: join(path, ".symbiot", "TASKS.md"), path, count: list.length, held });
477
520
  } catch (e) { unresolved.push({ name, count: list.length, error: String((e && e.message) || e) }); }
478
521
  }
479
522
  return { empty: false, written, unresolved, handoff: handoffCmd() };
480
523
  }
481
524
 
482
- 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 };
525
+ export { addTask, toggleTask, removeTask, restoreTask, completedInRepo, removalOf, applyRemovals, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, shipWithBump, runSummary, saidFinished, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
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">
@@ -483,6 +484,9 @@ list.forEach(function(r){var ch=r.files.length?(r.files.length+" file"+(r.files.
483
484
  h+="<div class='rcard' data-repo='"+esc(r.repo)+"'"+(r.publishesOnMerge?" data-pom='1'":"")+"><div class='rhead'><b>"+esc(r.repo)+"</b><span class='muted'>"+(r.path?"on "+esc(r.branch||'?')+" &middot; "+ch:"repo not found on disk")+"</span></div>";
484
485
  r.tasks.forEach(function(t){PENDTASKS.push(t);h+="<div class='task' data-id='"+esc(t.id)+"'><span class='t'>"+esc(t.text)+"</span>"+askBtn(t)+"<button class='rm sendback' title='not right - send back to the agent (unticks it)'>&#8630;</button></div>";});
485
486
  if(r.untasked)h+="<div class='muted' style='margin:6px 0'>Uncommitted changes with no ticked task behind them. Check the diff before you approve.</div>";
487
+ // The repo's open tasks: tick the ones these changes finished, and they're approved with them instead of going out again on the next send.
488
+ if(r.untasked&&r.open&&r.open.length){var said=r.open.some(function(o){return o.finished;});h+="<div class='muted' style='margin:6px 0'>Did these changes finish any of "+esc(r.repo)+"'s open tasks? Tick them and they're approved with the changes, so they don't come back as new tasks"+(said?". Ticked already: the run's summary says it finished them.":".")+"</div>";
489
+ r.open.forEach(function(o){h+="<label class='idea'><input type='checkbox' class='tickopen' data-id='"+esc(o.id)+"'"+(o.finished?" checked":"")+"><span>"+esc(o.text)+(o.finished?" <span class='tag'>run says done</span>":"")+"</span></label>";});}
486
490
  if(r.running)h+="<div class='muted' style='margin:6px 0'>&#9203; Agent still working: its changes may be half done. Approve unlocks when it finishes.</div>";
487
491
  // A repo that publishes on merge (ur.npm) is measured from the version npm has,
488
492
  // and a bump publishes it once merged; otherwise from the last v* tag, tagged by hand.
@@ -500,8 +504,12 @@ var amt=card.querySelector('.amtoggle');if(amt)amt.addEventListener('change',fun
500
504
  var sd=card.querySelector('.showdiff'),pre=card.querySelector('.rdiff');
501
505
  if(sd)sd.addEventListener('click',function(){if(!pre.classList.contains('hidden')){pre.classList.add('hidden');sd.textContent='Show diff';return;}
502
506
  pre.textContent='Loading...';pre.classList.remove('hidden');sd.textContent='Hide diff';api('/api/pending/diff?repo='+encodeURIComponent(repo)).then(function(d){pre.textContent=(d&&d.diff)||'(no changes)';});});
503
- var ap=card.querySelector('.approve'),bs=card.querySelector('.bumpsel');if(ap)ap.addEventListener('click',function(){ap.disabled=true;ap.textContent='Committing & pushing...';
504
- api(ap.getAttribute('data-untasked')?'/api/pending/approve-changes':'/api/pending/approve',{repo:repo,bump:bs?bs.value:''}).then(function(r){var o=document.getElementById('reviewout');
507
+ var ap=card.querySelector('.approve'),bs=card.querySelector('.bumpsel'),tk=card.querySelectorAll('.tickopen');
508
+ function ticks(){var ids=[];tk.forEach(function(c){if(c.checked)ids.push(c.getAttribute('data-id'));});return ids;}
509
+ function apLabel(){var n=ticks().length;if(ap&&!ap.disabled)ap.innerHTML=n?"Approve changes with "+n+" finished task"+(n>1?"s":"")+" &rarr; PR":"Approve changes without a task &rarr; PR";}
510
+ tk.forEach(function(c){c.addEventListener('change',apLabel);});if(tk.length)apLabel();
511
+ if(ap)ap.addEventListener('click',function(){var tick=ticks();ap.disabled=true;ap.textContent='Committing & pushing...';
512
+ api(ap.getAttribute('data-untasked')?'/api/pending/approve-changes':'/api/pending/approve',{repo:repo,bump:bs?bs.value:'',tick:tick}).then(function(r){var o=document.getElementById('reviewout');
505
513
  if(!r||r.error){ap.disabled=false;ap.textContent='Approve - retry';o.innerHTML="<div class='note err'>"+esc(repo)+": "+esc((r&&r.error)||'failed')+"</div>";return;}
506
514
  var m="&#10003; <b>"+esc(repo)+"</b>: approved "+(r.approved?r.approved+" task"+(r.approved>1?"s":""):"changes without a task");
507
515
  if(r.commit)m+=" &middot; committed <code>"+esc(r.commit)+"</code> on <code>"+esc(r.branch)+"</code>";
@@ -537,7 +545,7 @@ qs.forEach(function(q,i){var rl=q.release;h+="<div class='q' data-i='"+i+"'><div
537
545
  (q.options||[]).forEach(function(o,j){var off=rl&&rl.waiting&&DONEOPT.test(o);h+="<label class='opt"+(off?" off":"")+"'><input type='radio' name='q_"+esc(a.id)+"_"+i+"' value='"+j+"'"+(off?" disabled":"")+"><span>"+whoHtml(o)+(off?" <i>(once "+esc(rl.name+" "+rl.needs)+" is installed)</i>":"")+"</span></label>";});
538
546
  h+="<input class='qother' placeholder='"+((q.options&&q.options.length)?"or answer in your own words":"your answer")+"'></div>";});
539
547
  h+="<div class='row'><button class='act qsend' title='save the answers and hand the repo back to your agent'>Send answers &amp; continue</button><button class='ghost qsave' title='save the answers for the next run'>Save only</button></div>";}
540
- if(ss.length){var shown=IDEASOPEN[a.path]?ss.length:(k.ideasShown||ss.length);h+="<h4>&#128161; Ideas from the agent</h4>";ss.forEach(function(s,i){if(i>=shown)return;h+="<div class='idea'><span style='flex:1'>"+esc(s.text)+(s.other?" <span class='tag' title='this idea is for another project, so + task adds it to that one'>for "+esc(s.repo)+"</span>":"")+"</span>"+(s.added?"<span class='tag'>in Tasks</span>":"<button class='ghost qidea' data-i='"+i+"' style='padding:3px 9px;font-size:12px'>+ task</button>")+"</div>";});
548
+ if(ss.length){var shown=IDEASOPEN[a.path]?ss.length:(k.ideasShown||ss.length);h+="<h4>&#128161; Ideas from the agent</h4>";ss.forEach(function(s,i){if(i>=shown)return;h+="<div class='idea'><span style='flex:1'>"+esc(s.text)+(s.other?" <span class='tag' title='this idea is for another project, so + task adds it to that one'>for "+esc(s.repo)+"</span>":"")+"</span>"+(s.added?"<span class='tag'>in Tasks</span>":"<button class='ghost qidea' data-i='"+i+"' style='padding:3px 9px;font-size:12px'>+ task</button><button class='ghost qskip' data-i='"+i+"' title='turn this idea down: the next one moves up, and the agent is told not to suggest it again' style='padding:3px 9px;font-size:12px'>Skip</button>")+"</div>";});
541
549
  if(ss.length>shown)h+="<div class='row'><button class='ghost qmore' title='the agent ranks its ideas best first: weigh these two before adding more'>"+(ss.length-shown)+" more idea"+(ss.length-shown>1?"s":"")+", ranked lower</button></div>";}
542
550
  return h+"</div>";}
543
551
  var IDEASOPEN={};
@@ -557,14 +565,22 @@ var s1=box.querySelector('.qsend'),s2=box.querySelector('.qsave');
557
565
  if(s1)s1.addEventListener('click',function(){send(true);});if(s2)s2.addEventListener('click',function(){send(false);});
558
566
  var more=box.querySelector('.qmore');if(more)more.addEventListener('click',function(){IDEASOPEN[a.path]=true;loadAgents();});
559
567
  box.querySelectorAll('.qidea').forEach(function(btn){btn.addEventListener('click',function(){var s=a.ask.suggestions[+btn.getAttribute('data-i')];if(!s)return;btn.disabled=true;
560
- api('/api/tasks/add',{text:s.text,repo:s.repo||a.name}).then(function(){s.added=true;btn.outerHTML="<span class='tag'>in Tasks</span>";});});});});}
568
+ var sk=btn.parentNode.querySelector('.qskip');if(sk)sk.remove();
569
+ api('/api/tasks/add',{text:s.text,repo:s.repo||a.name}).then(function(){s.added=true;btn.outerHTML="<span class='tag'>in Tasks</span>";});});});
570
+ box.querySelectorAll('.qskip').forEach(function(btn){btn.addEventListener('click',function(){var s=a.ask.suggestions[+btn.getAttribute('data-i')];if(!s)return;btn.disabled=true;
571
+ api('/api/agents/skip',{path:a.path,text:s.text}).then(function(r){if(!r||r.error){btn.disabled=false;document.getElementById('agentsmsg').innerHTML="<div class='note err'>"+esc((r&&r.error)||'failed')+"</div>";return;}loadAgents();});});});});}
561
572
  // A step of yours an answer picked (👤 You): the next run waits for it (agents.mjs waitingFor)
562
573
  function waitHtml(a){var w=a.waiting;if(!w)return '';var fs=(w.files||[]).map(function(f){return "<code>"+esc(f)+"</code>";}).join(" or ");if(w.cmd)fs=(fs?fs+" or ":"")+"the agent command in Settings &rarr; Handoff";
563
574
  return "<div class='aq'><h4>&#9208; Waiting on your step</h4><div class='qt'>&#128100; "+esc(w.step)+"</div><div class='qc'>"+(fs?"Your agent "+(w.rerun?"starts by itself":"can start")+" once "+fs+" changes"+(w.rerun?", while Symbiot runs":"")+". Done it some other way?":"Your agent waits for it, so it doesn't stop on the same questions again.")+" Start it now once it's done.</div><div class='row'><button class='act wstart' data-id='"+esc(a.id)+"'>Start it now</button></div></div>";}
564
575
  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
576
  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'>&#10003; 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&#39;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
577
  function answering(){var f=document.activeElement;return !!(f&&f.closest&&f.closest('.aq'));}
567
- function loadAgents(){api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
578
+ // Handovers (lanes.mjs): what one lane's agent handed to another, and where it stands.
579
+ var LANEWORD={started:'working on it',held:'queued: that lane is busy',done:'done, reported back',error:"couldn't hand over"};
580
+ function loadLanes(){api('/api/lanes').then(function(d){var el=document.getElementById('laneslist');if(!el||!d)return;var hs=d.handoffs||[];
581
+ el.innerHTML=hs.length?"<div class='tgroup'>Handovers <span class='tcount'>"+hs.length+"</span></div>"+hs.slice(0,12).map(function(h){
582
+ return "<div class='task' title='"+escQ(h.result||h.error||h.text)+"'><span class='t'><b>"+esc(h.from)+"</b> &rarr; <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(''):'';});}
583
+ function loadAgents(){loadLanes();api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
568
584
  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) &mdash; you'll watch it work here.</div>";stopAgentsPoll();return;}
569
585
  var anyRunning=list.some(function(a){return a.status==='running';});
570
586
  if(answering()){stopAgentsPoll();if(anyRunning&&current==='agents')agentsTimer=setTimeout(loadAgents,2000);return;} // don't re-render under someone typing an answer
package/watch.mjs CHANGED
@@ -31,7 +31,8 @@ import { desktopNotify } from "./desktop.mjs";
31
31
  import { resolveProvider, write } from "./ai.mjs";
32
32
  import { handoffCmd, runHandoff, runningHandoff } from "./agents.mjs";
33
33
  import { linksIn, peekLinks, peekLine } from "./peek.mjs";
34
- import { converse, actNow, addToTasks } from "./mind.mjs";
34
+ import { converse, actNow, actIn, taskIn } from "./mind.mjs";
35
+ import { repoPathMap } from "./scan.mjs";
35
36
 
36
37
  const WATCH_FILE = join(CONFIG_DIR, "watch.json");
37
38
  const EVERY = [5, 15, 30, 60]; // minutes between reads
@@ -257,7 +258,7 @@ function cardLinks(w, recent, question) {
257
258
  const own = hostOf(w.url), host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ""); } catch { return ""; } };
258
259
  return [...linksIn(question), ...recent.flatMap((n) => linksIn(n.text))].filter((u) => host(u) && host(u) !== own);
259
260
  }
260
- async function boardChat(id, question, { hours = 72, now = Date.now(), ask = write, look = peekLinks, run = actNow } = {}) {
261
+ async function boardChat(id, question, { hours = 72, now = Date.now(), ask = write, look = peekLinks, run = actNow, lanes = () => { try { return repoPathMap(); } catch { return {}; } } } = {}) {
261
262
  question = String(question || "").trim().slice(0, 2000);
262
263
  const d = loadWatch(), w = d.watches.find((x) => x.id === id); if (!w) return { error: `No watch ${id}.` };
263
264
  if (!question) return { error: "empty" };
@@ -272,7 +273,9 @@ async function boardChat(id, question, { hours = 72, now = Date.now(), ask = wri
272
273
  const context = `${w.name}, new in the last ${hours} hours (newest first):\n${listed}` + (b ? `\n\nThe brief:\n${b.text}` : "") + (peeks.length ? `\n\nWhat's behind the links (looked up just now):\n${peeks.map(peekLine).join("\n")}` : "");
273
274
  const history = (w.chat || []).slice(-12).map((m) => `${m.role === "user" ? "User" : "You"}: ${m.text}`).join("\n\n");
274
275
  const r = await converse({ where: `${w.name} card`, role, context, history, question, ask, now,
275
- act: { agent: (req, known) => run(req, { title: w.name, context: `${w.name}, new lately:\n${listed}`, known, now }), task: addToTasks } });
276
+ act: { // a repo named: its lane (its tasks, its agent); none: an agent of its own
277
+ agent: (req, known, repo) => (repo ? actIn(req, repo, { map: lanes(), known, title: w.name, context: `${w.name}, new lately:\n${listed}`, ops: run, now }) : run(req, { title: w.name, context: `${w.name}, new lately:\n${listed}`, known, now })),
278
+ task: (text, repo) => taskIn(text, repo, { map: lanes() }) } });
276
279
  const answer = r.reply;
277
280
  // read again: a check may have saved the file while the model answered
278
281
  const d2 = loadWatch(), w2 = d2.watches.find((x) => x.id === id); if (!w2) return { answer, chat: [] };
package/writeups.mjs CHANGED
@@ -8,9 +8,8 @@ import { resolveProvider, write } from "./ai.mjs";
8
8
  import { detectMailSources, mailActivity } from "./mail.mjs";
9
9
  import { waitingOn, newsSince } from "./watch.mjs";
10
10
  import { me, authorship, authorArgs, readmeInfo, repoShape, houseRules, reportFooter, expandRoot, commits, openWork, detectFolder, repoPathMap, discoveredRepos } from "./scan.mjs";
11
- import { taskType, workingChanges, workingDiff, addTask, pushTasks } from "./tasks.mjs";
12
- import { runHandoff } from "./agents.mjs";
13
- import { converse, actNow, addToTasks } from "./mind.mjs";
11
+ import { taskType, workingChanges, workingDiff } from "./tasks.mjs";
12
+ import { converse, actNow, actIn, taskIn } from "./mind.mjs";
14
13
 
15
14
  // ---- render ---------------------------------------------------------------
16
15
  function renderCommits(list) {
@@ -152,14 +151,12 @@ async function taskChat(id, question) {
152
151
  (ctx.length ? "\n\n" + ctx.join("\n\n") : path ? "" : "\n\n(no repo attached: answer from the task text alone)");
153
152
  // "do it" here works on the task's repo the way Send to repos does: the request
154
153
  // joins the repo's tasks and the agent starts there; with no repo, in a run of its own
155
- const agent = async (req, known) => {
156
- if (!path) return actNow(req, { title: it.text.slice(0, 60), context: `Their task: ${it.text}`, known });
157
- const t = addTask(req, it.repo); if (t && t.error) return { error: t.error };
158
- const p = pushTasks({ repo: it.repo }); if (!p.written || !p.written.length) return { error: `Couldn't write ${it.repo}'s tasks for the agent.` };
159
- const e = runHandoff(path); if (!e) return { error: "Your agent didn't start. Check its command in Settings → Handoff." };
160
- return e.busy ? { ok: true, note: "Your agent is already working in that repo: it takes this up when it's done." } : { ok: true, job: e.id };
154
+ const lanes = repoPathMap(), mine = path ? it.repo : "";
155
+ const agent = async (req, known, repo) => {
156
+ const lane = repo || mine;
157
+ return lane ? actIn(req, lane, { map: lanes, known, title: it.text.slice(0, 60), context: `Their task: ${it.text}` }) : actNow(req, { title: it.text.slice(0, 60), context: `Their task: ${it.text}`, known });
161
158
  };
162
- const r = await converse({ where: `Task: ${it.text.slice(0, 60)}`, role, context, history, question, act: { agent, task: (text, repo) => addToTasks(text, repo || it.repo) } });
159
+ const r = await converse({ where: `Task: ${it.text.slice(0, 60)}`, role, context, history, question, map: lanes, act: { agent, task: (text, repo) => taskIn(text, repo || mine, { map: lanes }) } });
163
160
  const answer = r.reply;
164
161
  // Re-read: other requests may have changed tasks.json while the model ran.
165
162
  const t = loadTasks(); const cur = t.find((x) => x.id === id); const now = Date.now();