symbiot 0.43.0 โ†’ 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 CHANGED
@@ -67,6 +67,8 @@ Tabs:
67
67
  any tab, and once you watch something the app opens on the Dashboard instead of
68
68
  the Map. `symbiot watch board` prints the same cards as JSON, and
69
69
  `symbiot watch board --line` as one line for a status bar (`2 emails ยท 1 WhatsApp message`).
70
+ `symbiot watch chat <id> "question"` is the card's ๐Ÿ’ฌ chat from a terminal, so an
71
+ agent can go over what's new with you too.
70
72
  - **Drift** โ€” the [`symbiot drift`](#whats-out-of-sync-symbiot-drift) report, with
71
73
  a "fetch latest" toggle (and an [experimental](#experimental) "check CI").
72
74
  - **Week / Standup / Todo** โ€” the write-ups (these use your chosen AI).
@@ -381,7 +383,15 @@ Reading both costs about 40 lines.
381
383
  ```
382
384
 
383
385
  Up to five questions show on that agent's block in the **Agents** tab, each with
384
- its options and room for your own answer, alongside its ideas. Each option says
386
+ two options and room for your own answer, alongside its ideas, two at a time.
387
+ Two, because most people pick the recommended option or add every idea without
388
+ weighing them, so the agent is told to be the judge. Both options have to be good
389
+ routes, each saying in plain words what it changes for the project, the people on
390
+ it and the company. The recommended one has to be the best for, in that order,
391
+ the company, the people doing the work and the goal, based on evidence the agent
392
+ checked (git history, tests, logs). An idea that's in your Tasks leaves the list,
393
+ and the next one moves up. **Suggest next steps** on the Map follows the same rule
394
+ and suggests two. Each option says
385
395
  who acts: **You** (a setting, a click, a command) or **Agent** (picking it is
386
396
  enough). A question that needs a release ("Once 0.41.0 is installed: โ€ฆ") shows
387
397
  the version installed here and the one on npm, and holds back a "Done" answer
@@ -401,8 +411,10 @@ committed. The questions stay on the Agents tab after Symbiot restarts (or when
401
411
  the answer and reminds you the step is still yours to do. Your agent waits for it
402
412
  instead of starting straight away and asking the same thing again. If the step
403
413
  names a file in backticks (`` `.env` ``), the agent starts by itself once that
404
- file changes, while the app runs. Otherwise **Start it now** on that folder's
405
- block starts it once you've done the step.
414
+ file changes, while the app runs. A step in Settings (**Allow command**, a
415
+ connector) changes the agent command rather than a file, so a changed agent
416
+ command counts as the step being done too. Otherwise **Start it now** on that
417
+ folder's block starts it once you've done the step.
406
418
 
407
419
  **A run that stopped on questions isn't repeated for nothing.** When a run ends
408
420
  having asked questions you haven't answered, with tasks still unticked, Symbiot
@@ -496,6 +508,29 @@ can't do.
496
508
  app, so context carries from page to page without re-sending everything.
497
509
  Settings โ†’ **What Symbiot remembers** lists it, and forgets any of it.
498
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
+
499
534
  ## Connect it (once)
500
535
 
501
536
  Pick the AI you want it to write with:
@@ -770,6 +805,7 @@ symbiot watch new --hours 24 # what's new, newest first (JSON)
770
805
  symbiot watch board # the Dashboard's cards (JSON; .total is the count, for a status bar)
771
806
  symbiot watch board --line # the same as one line: "2 emails ยท 1 WhatsApp message" (empty when nothing's new)
772
807
  symbiot watch seen <watch id> # set a card back to 0, like its Seen button
808
+ symbiot watch chat <watch id> "what needs me?" # talk a card over with your AI, like its ๐Ÿ’ฌ (--clear starts over)
773
809
  symbiot watch check # read them all now (JSON)
774
810
  symbiot watch draft <id> # Draft a reply to a new email or chat message (see below)
775
811
  symbiot watch brief on # your AI says what needs you (off to stop)
package/agents.mjs CHANGED
@@ -112,9 +112,9 @@ function runHandoff(repoPath, { force = false } = {}) {
112
112
  const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid, auto: !!busy.auto };
113
113
  releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
114
114
  tmpl = withConnectors(tmpl, repoPath);
115
- if (force) { try { unlinkSync(join(repoPath, ".symbiot", WAITING)); } catch {} }
116
- else { const w = waitingFor(repoPath); if (w) return { blocked: true, waiting: true, questions: 0, note: waitNote(w) }; }
115
+ if (!force) { const w = waitingFor(repoPath); if (w) return { blocked: true, waiting: true, questions: 0, note: waitNote(w) }; }
117
116
  const blocked = !force && blockedAgain(repoPath, tmpl); if (blocked) return blocked;
117
+ try { unlinkSync(join(repoPath, ".symbiot", WAITING)); } catch {} // the step's done (or this is Start it anyway): this is the run it waited for
118
118
  const lock = join(repoPath, ".symbiot", LOCK);
119
119
  const env = opts.env && typeof opts.env === "object" ? Object.fromEntries(Object.entries(opts.env).map(([k, v]) => [k, String(v)])) : null;
120
120
  const e = track(typeof opts.name === "string" && opts.name ? opts.name.slice(0, 80) : repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath, (code) => {
@@ -165,11 +165,17 @@ function earlierRuns() {
165
165
  // only gets the same questions back. So answerQuestions reminds them the step is
166
166
  // theirs, and Send answers & continue waits instead of starting: .symbiot/
167
167
  // waiting.json keeps the step and how each file it names (in backticks, like
168
- // `.env`) was then. Once one of them changes, the next run goes ahead
169
- // (startWaiting starts the one that was asked for); with no file to watch, it
168
+ // `.env`) was then, and the agent command: a step done in Settings (Allow
169
+ // command, a connector) changes that, not a file, so it counts as done too, as
170
+ // it does for blocked.json. Once one of them changes, the next run goes ahead
171
+ // (startWaiting starts the one that was asked for); with neither changed, it
170
172
  // waits for Start it now. Start it anyway (force) always goes ahead.
171
173
  const WAITING = "waiting.json";
172
174
  const yourStep = (a) => /๐Ÿ‘ค/.test(String(a || ""));
175
+ // A step in Settings โ†’ Handoff: the note names the command as what it waits on.
176
+ const settingsStep = (s) => /\bsettings\b|\ballow\b|\bgrant|connector/i.test(String(s || ""));
177
+ // The command the folder's next run would get, connectors and all (runHandoff).
178
+ const runCmd = (path) => digest(withConnectors(handoffCmd(), path));
173
179
  // The files a step names, in backticks: `.env`, `~/.termux/termux.properties`,
174
180
  // `.claude/settings.json`. Not a command (`npm test`), a site (`web.whatsapp.com`,
175
181
  // unless there's a file by that name) or a path that can't be one here.
@@ -186,27 +192,36 @@ function namedFiles(text, dir) {
186
192
  return out.filter((f, i) => out.findIndex((g) => g.path === f.path) === i).slice(0, 5);
187
193
  }
188
194
  const fileSig = (p) => { try { const s = statSync(p); return s.mtimeMs + ":" + s.size; } catch { return "none"; } };
189
- // What a folder is waiting on: { step, files: [names], at, rerun }, or null (and
190
- // waiting.json goes) once a file it names has changed.
195
+ // What a folder is waiting on: { step, files: [names], cmd, at, rerun }, or null
196
+ // once a file it names, or the agent command, has changed. Then waiting.json
197
+ // goes, unless a run was asked for: startWaiting needs it to start that one (the
198
+ // Agents tab asks this every few seconds, and used to lose it first), and the
199
+ // run deletes it as it starts. cmd: the step is one in Settings, so the note says
200
+ // the command counts.
191
201
  function waitingFor(path) {
192
202
  let w = null; try { w = JSON.parse(readSymbiot(path, WAITING)); } catch {}
193
203
  if (!w || typeof w.step !== "string") return null;
194
204
  const files = Array.isArray(w.files) ? w.files : [];
195
- if (files.some((f) => f && fileSig(f.path) !== f.sig)) { try { unlinkSync(join(path, ".symbiot", WAITING)); } catch {} return null; }
196
- return { step: w.step, files: files.map((f) => f.name), at: w.at || 0, rerun: !!w.rerun };
205
+ if (files.some((f) => f && fileSig(f.path) !== f.sig) || (w.cmd && runCmd(path) !== w.cmd)) { if (!w.rerun) try { unlinkSync(join(path, ".symbiot", WAITING)); } catch {} return null; }
206
+ return { step: w.step, files: files.map((f) => f.name), cmd: !!w.cmd && settingsStep(w.step), at: w.at || 0, rerun: !!w.rerun };
207
+ }
208
+ // "`.env` or the agent command in Settings โ†’ Handoff", what a wait ends on ("" if neither).
209
+ function waitOn(files, cmd) {
210
+ const names = files.map((f) => "`" + f + "`").join(" or ");
211
+ return cmd ? (names ? names + " or " : "") + "the agent command (Settings โ†’ Handoff)" : names;
197
212
  }
198
213
  function waitNote(w) {
199
- const names = w.files.map((f) => "`" + f + "`").join(" or ");
200
- return `Not started: your step comes first: ${w.step.replace(/^\s*๐Ÿ‘ค\s*(You:)?\s*/, "")} ${names ? `Your agent starts once ${names} changes.` : "Once it's done, click Start it now (or Start it anyway)."}`;
214
+ const on = waitOn(w.files, w.cmd);
215
+ return `Not started: your step comes first: ${w.step.replace(/^\s*๐Ÿ‘ค\s*(You:)?\s*/, "")} ${on ? `Your agent starts once ${on} changes. Done it some other way? Click Start it now.` : "Once it's done, click Start it now (or Start it anyway)."}`;
201
216
  }
202
- // Runs that waited for a file of yours, and were asked for, start once it changes.
203
- // The app calls this every so often. Returns the jobs started.
217
+ // Runs that waited for a file of yours (or the agent command), and were asked
218
+ // for, start once it changes. The app calls this every so often. Returns the jobs started.
204
219
  function startWaiting() {
205
220
  const started = [];
206
221
  for (const r of loadRuns()) {
207
222
  let w = null; try { w = JSON.parse(readSymbiot(r.path, WAITING)); } catch {}
208
- if (!w || !w.rerun || !Array.isArray(w.files) || !w.files.length || runningHandoff(r.path)) continue;
209
- if (waitingFor(r.path)) continue; // nothing it names has changed yet
223
+ if (!w || !w.rerun || !Array.isArray(w.files) || !(w.files.length || w.cmd) || runningHandoff(r.path)) continue;
224
+ if (waitingFor(r.path)) continue; // nothing it names, nor the command, has changed yet
210
225
  const e = runHandoff(r.path); if (e && !e.busy && !e.blocked) started.push(e);
211
226
  }
212
227
  return started;
@@ -438,6 +453,12 @@ function agentChanges(path, startedAt) {
438
453
  // .symbiot/ANSWERS.md for the next run to read. Plain files, so it works the
439
454
  // same whichever model or tool the agent is.
440
455
  const QUESTIONS_MAX = 5;
456
+ // Two at a time: most people pick the recommended option, or + task an idea,
457
+ // without weighing the rest, and the choices are the agent's to judge (the
458
+ // brief says how). So a question shows its first two options, and the Agents
459
+ // tab the ideas two at a time (`ideasShown`): one in Tasks leaves the list, and
460
+ // the next moves up. The rest are a click away.
461
+ const OPTIONS_SHOWN = 2, IDEAS_SHOWN = 2;
441
462
  const qKey = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ");
442
463
  const readSymbiot = (path, f) => { try { return readFileSync(join(path, ".symbiot", f), "utf8"); } catch { return ""; } };
443
464
  // "## Questions" โ†’ "### question", context lines, "- option" bullets;
@@ -479,9 +500,10 @@ function agentQuestions(path, repo) {
479
500
  const done = new Set([...readSymbiot(path, "ANSWERS.md").matchAll(/^###\s+(.+)$/gm)].map((m) => qKey(m[1])));
480
501
  const open = p.questions.filter((x) => !done.has(qKey(x.q)));
481
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);
482
504
  return {
483
- questions: open.slice(0, QUESTIONS_MAX), answered: p.questions.length - open.length,
484
- suggestions: 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)) })),
505
+ questions: open.slice(0, QUESTIONS_MAX).map((x) => ({ ...x, options: x.options.slice(0, OPTIONS_SHOWN) })), answered: p.questions.length - open.length,
506
+ suggestions: ideas, ideasShown: IDEAS_SHOWN,
485
507
  };
486
508
  }
487
509
  // Save answers to .symbiot/ANSWERS.md; opts.rerun hands the repo back to the
@@ -508,10 +530,10 @@ function answerQuestions(path, answers, opts = {}) {
508
530
  const yours = rows.filter((x) => yourStep(x.a)).map((x) => stepText(x.a)).filter(Boolean);
509
531
  if (yours.length) {
510
532
  const step = yours.join(" "), files = namedFiles(rows.filter((x) => yourStep(x.a)).map((x) => x.a.split("๐Ÿค–")[0]).join(" "), path);
511
- try { writeFileSync(join(path, ".symbiot", WAITING), JSON.stringify({ step, files: files.map((f) => ({ ...f, sig: fileSig(f.path) })), at: Date.now(), rerun: !!opts.rerun })); } catch {}
512
- const names = files.map((f) => "`" + f.name + "`").join(" or ");
533
+ try { writeFileSync(join(path, ".symbiot", WAITING), JSON.stringify({ step, files: files.map((f) => ({ ...f, sig: fileSig(f.path) })), cmd: runCmd(path), at: Date.now(), rerun: !!opts.rerun })); } catch {}
534
+ const on = waitOn(files.map((f) => f.name), settingsStep(step));
513
535
  out.yours = yours; if (files.length) out.waitFiles = files.map((f) => f.name);
514
- out.note = "Answers saved. That step is still yours to do. " + (files.length ? `Your agent ${opts.rerun ? "starts by itself" : "can start"} once ${names} changes${opts.rerun ? " (while Symbiot runs)" : ""}.` : "Once it's done, click Start it now on this folder.");
536
+ out.note = "Answers saved. That step is still yours to do. " + (on ? `Your agent ${opts.rerun ? "starts by itself" : "can start"} once ${on} changes${opts.rerun ? " (while Symbiot runs)" : ""}.` : "Once it's done, click Start it now on this folder.");
515
537
  return out;
516
538
  }
517
539
  if (opts.rerun) { const e = runHandoff(path); if (e && e.busy) out.note = "Answers saved. An agent is still running in that folder, so another wasn't started. Send them again once it finishes."; else if (e && e.blocked) out.note = "Answers saved. " + e.note; else if (e) out.rerun = e.id; else out.note = "Answers saved. Set an agent command in Settings to have the agent pick them up automatically."; }
@@ -535,4 +557,4 @@ function agentsList() {
535
557
  }).concat(earlierRuns());
536
558
  }
537
559
 
538
- export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, 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 };
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 };
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/index.mjs CHANGED
@@ -39,7 +39,7 @@ import { buildTasksMd, taskType, shipChanges, shipWithBump, bumpOffer, learnNpm,
39
39
  import { produce, mailState, setMail, sentMail } from "./writeups.mjs";
40
40
  import { loadScreens, screenImage, blueprint } from "./screens.mjs";
41
41
  import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, isTrusted } from "./headless.mjs";
42
- import { watchState, addWatch, removeWatch, seenWatch, newsSince, markNews, checkWatch, setBrief, draftReply, watchBoard, boardLine } from "./watch.mjs";
42
+ import { watchState, addWatch, removeWatch, seenWatch, newsSince, markNews, checkWatch, setBrief, draftReply, watchBoard, boardLine, boardChat, boardTalk, clearBoardChat } from "./watch.mjs";
43
43
  import { PORT as PHONE_PORT, phoneState, pairComputer, pollComputer, forgetComputer } from "./phone.mjs";
44
44
  import { startApp, updateCmd, isAppRunningWeekly } from "./server.mjs";
45
45
 
@@ -380,7 +380,7 @@ async function cmdScreens() {
380
380
  // ---- `symbiot watch`: pages Symbiot keeps track of, and what's new on them ----
381
381
  // new, board, seen, add, remove and check print JSON, for you or an agent.
382
382
  async function cmdWatch() {
383
- const [sub = "list", a1] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && !["--every", "--hours"].includes(all[i - 1]));
383
+ const [sub = "list", a1, a2] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && !["--every", "--hours"].includes(all[i - 1]));
384
384
  const out = (x) => { console.log(JSON.stringify(x, null, 2)); if (x && x.error) process.exitCode = 1; };
385
385
  const hours = Number(flag("hours", 24)) || 24;
386
386
  if (sub === "list") {
@@ -404,6 +404,17 @@ async function cmdWatch() {
404
404
  const where = r.chat ? "types it into the chat's message box in Symbiot's browser, never sent" : "leaves it in Drafts, never sent";
405
405
  return out(r.ok ? { ...r, next: `Your agent is writing the reply and ${where}. Its log is in ` + join(r.dir, ".symbiot", "agent.log") + " (and the app's Agents tab)." } : r);
406
406
  }
407
+ // a card's chat, the same talk as ๐Ÿ’ฌ on the Dashboard (through the app when it
408
+ // runs, so an agent it starts shows in its Agents tab): a reply drafted from that
409
+ // card brings it along. No question: the talk so far; --clear starts it over.
410
+ if (sub === "chat") {
411
+ if (!a1) return out({ error: "Give the watch's id: symbiot watch board lists them. Then symbiot watch chat <id> \"what needs me?\"" });
412
+ if (has("clear")) return out((await viaApp("/api/watch/chat/clear", { id: a1 })) || clearBoardChat(a1));
413
+ if (!a2) return out(boardTalk(a1));
414
+ const r = (await viaApp("/api/watch/chat", { id: a1, question: a2 })) || await boardChat(a1, a2);
415
+ if (r.error === "not-connected") return out({ error: "Connect a model first: symbiot login (or Settings in the app)." });
416
+ const { chat, ...rest } = r; return out(chat ? { ...rest, messages: chat.length } : rest);
417
+ }
407
418
  if (sub === "add") { const screen = loadScreens().some((s) => s.id === a1) ? a1 : ""; return out(addWatch({ screen, site: screen ? "" : a1, every: flag("every", 15) })); }
408
419
  if (sub === "remove") return out(removeWatch(String(a1 || "")));
409
420
  if (sub === "brief") { if (a1 === "on" || a1 === "off") return out(setBrief(a1 === "on")); return out({ brief: watchState().brief, briefs: watchState().briefs }); }
@@ -428,6 +439,10 @@ async function cmdWatch() {
428
439
  empty line when nothing's new)
429
440
  symbiot watch seen <id> set a card's count back to 0, like its Seen
430
441
  button (what it found stays in watch new)
442
+ symbiot watch chat <id> "question" talk over what's new on a card with your AI,
443
+ like ๐Ÿ’ฌ on the Dashboard: a reply drafted from
444
+ that card follows what you agreed (JSON)
445
+ symbiot watch chat <id> [--clear] the talk so far, or start it over
431
446
  symbiot watch check [id] read them now (JSON)
432
447
  symbiot watch draft <id> your agent drafts a reply to that new email
433
448
  ("mail": true in watch new) and leaves it in
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
- - 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 }.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.43.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,7 +7,8 @@ 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 { QUESTIONS_MAX, handoffCmd, runningHandoff, writeTasks, startHeldTasks } from "./agents.mjs";
10
+ import { handoverRules, ONLY_YOU } from "./handover.mjs";
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";
13
14
 
@@ -435,12 +436,14 @@ 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
- `- At most ${QUESTIONS_MAX} questions, under a \`## Questions\` heading. Each is a \`### \` heading, then an optional line of context, then 2โ€“4 options as \`- \` bullets (put the one you recommend first).`,
440
- "- 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.",
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.`,
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.",
443
+ ONLY_YOU,
441
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.",
442
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.",
443
- "- Ideas, options or follow-ups outside these tasks go under `## Suggestions` as `- ` bullets โ€” the user can add them to their tasks in one click.",
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.`,
444
447
  "- 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.",
445
448
  "- 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.", "");
446
449
  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."');
@@ -471,7 +474,7 @@ function pushTasks(filter) {
471
474
  if (st.behind) risk.push(`${st.behind} behind upstream on ${st.branch}`);
472
475
  if (st.dirty && !st.stale) risk.push(`${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)`);
473
476
  // held: an agent is still running there, so it lands when that one exits
474
- 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));
475
478
  written.push({ name, file: join(path, ".symbiot", "TASKS.md"), path, count: list.length, held });
476
479
  } catch (e) { unresolved.push({ name, count: list.length, error: String((e && e.message) || e) }); }
477
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">
@@ -537,8 +538,10 @@ qs.forEach(function(q,i){var rl=q.release;h+="<div class='q' data-i='"+i+"'><div
537
538
  (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
539
  h+="<input class='qother' placeholder='"+((q.options&&q.options.length)?"or answer in your own words":"your answer")+"'></div>";});
539
540
  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){h+="<h4>&#128161; Ideas from the agent</h4>";ss.forEach(function(s,i){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>";});}
541
+ 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>";});
542
+ 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>";}
541
543
  return h+"</div>";}
544
+ var IDEASOPEN={};
542
545
  function qKeyOf(qe){var box=qe.closest('.aq');var a=box&&agentById(box.getAttribute('data-id'));var q=a&&a.ask.questions[+qe.getAttribute('data-i')];return q?a.path+'|'+q.q:'';}
543
546
  function saveDrafts(el){el.querySelectorAll('.aq .q').forEach(function(qe){var k=qKeyOf(qe);if(!k)return;var pick=qe.querySelector('input[type=radio]:checked');QDRAFT[k]={o:pick?pick.value:'',t:qe.querySelector('.qother').value};});}
544
547
  function restoreDrafts(el){el.querySelectorAll('.aq .q').forEach(function(qe){var d=QDRAFT[qKeyOf(qe)];if(!d)return;qe.querySelectorAll('input[type=radio]').forEach(function(r){r.checked=!r.disabled&&r.value===d.o;});qe.querySelector('.qother').value=d.t||'';});}
@@ -553,15 +556,21 @@ if(!r||r.error){box.querySelectorAll('button').forEach(function(b){b.disabled=fa
553
556
  msg.innerHTML="<div class='note ok'>&#10003; Saved "+r.saved+" answer"+(r.saved>1?"s":"")+" for <b>"+esc(a.name)+"</b> in .symbiot/ANSWERS.md"+(r.rerun?" &middot; your agent is picking them up now.":".")+(r.yours&&r.yours.length?"<div class='err'>&#128100; Still yours to do: "+esc(r.yours.join(' '))+"</div>":"")+(r.note?"<div class='muted'>"+esc(r.note)+"</div>":"")+"</div>";loadAgents();});}
554
557
  var s1=box.querySelector('.qsend'),s2=box.querySelector('.qsave');
555
558
  if(s1)s1.addEventListener('click',function(){send(true);});if(s2)s2.addEventListener('click',function(){send(false);});
559
+ var more=box.querySelector('.qmore');if(more)more.addEventListener('click',function(){IDEASOPEN[a.path]=true;loadAgents();});
556
560
  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;
557
561
  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>";});});});});}
558
562
  // A step of yours an answer picked (๐Ÿ‘ค You): the next run waits for it (agents.mjs waitingFor)
559
- function waitHtml(a){var w=a.waiting;if(!w)return '';var fs=(w.files||[]).map(function(f){return "<code>"+esc(f)+"</code>";}).join(" or ");
563
+ 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";
560
564
  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>";}
561
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;
562
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'>&#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>";});});});}
563
567
  function answering(){var f=document.activeElement;return !!(f&&f.closest&&f.closest('.aq'));}
564
- function loadAgents(){api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
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> &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(''):'';});}
573
+ function loadAgents(){loadLanes();api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
565
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) &mdash; you'll watch it work here.</div>";stopAgentsPoll();return;}
566
575
  var anyRunning=list.some(function(a){return a.status==='running';});
567
576
  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
@@ -280,6 +280,8 @@ async function boardChat(id, question, { hours = 72, now = Date.now(), ask = wri
280
280
  saveWatch(d2);
281
281
  return { answer, chat: w2.chat, ...(peeks.length ? { links: peeks } : {}), ...(r.did ? { did: r.did } : {}) };
282
282
  }
283
+ // The card's talk so far (symbiot watch chat <id>), [{ role, text, ts }].
284
+ function boardTalk(id) { const w = loadWatch().watches.find((x) => x.id === id); return w ? { chat: w.chat || [] } : { error: `No watch ${id}.` }; }
283
285
  function clearBoardChat(id) { const d = loadWatch(), w = d.watches.find((x) => x.id === id); if (!w) return { error: `No watch ${id}.` }; delete w.chat; saveWatch(d); return { ok: true }; }
284
286
  // The card's talk, for a draft's brief: "" when there's been none.
285
287
  function talkOf(w) {
@@ -493,4 +495,4 @@ function startWatches(opts = {}) {
493
495
  return () => { clearTimeout(first); clearInterval(every); };
494
496
  }
495
497
 
496
- export { LINK_ASK, WATCH_FILE, EVERY, GITHUB_INBOX, DRAFTS_DIR, itemsOf, itemKey, newItems, remember, isGitHubInbox, githubItems, readGitHub, setBrief, briefOf, newsNotice, markNews, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, newsSince, newsAfter, waitingOn, watchBoard, boardLine, boardChat, clearBoardChat, talkOf, isMail, isChat, draftsUrl, draftBrief, chatBrief, draftReply, openChat, checkWatch, dueWatches, startWatches };
498
+ export { LINK_ASK, WATCH_FILE, EVERY, GITHUB_INBOX, DRAFTS_DIR, itemsOf, itemKey, newItems, remember, isGitHubInbox, githubItems, readGitHub, setBrief, briefOf, newsNotice, markNews, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, newsSince, newsAfter, waitingOn, watchBoard, boardLine, boardChat, boardTalk, clearBoardChat, talkOf, isMail, isChat, draftsUrl, draftBrief, chatBrief, draftReply, openChat, checkWatch, dueWatches, startWatches };
package/writeups.mjs CHANGED
@@ -69,6 +69,12 @@ function extractJson(s) {
69
69
  if (a >= 0 && b > a) { try { return JSON.parse(t.slice(a, b + 1)); } catch {} }
70
70
  return null;
71
71
  }
72
+ // Two at a time, judged (as agents' questions are, agents.mjs OPTIONS_SHOWN):
73
+ // most people act on whatever's suggested without weighing it, so the model
74
+ // picks, and says why in a line.
75
+ const JUDGE = `Judge them before you write them: people tend to do whatever is listed without weighing it, and this is for a whole company, not one person. ` +
76
+ `List only what you'd recommend, best first, and choose by what's best for, in this order, the company, the people doing the work, then the project's goal. ` +
77
+ `For each, say in plain words (no jargon) what it changes from then on and the evidence for it. `;
72
78
  // AI "value" suggestions for one repo, from its recent commits + open work.
73
79
  async function repoSuggest(path) {
74
80
  if (!resolveProvider()) return { error: "not-connected" };
@@ -81,8 +87,8 @@ async function repoSuggest(path) {
81
87
  const footer = reportFooter(path, auth, state, rd);
82
88
  const system =
83
89
  `You are a pragmatic senior engineer advising on one project. Write exactly three short sections with headings: ` +
84
- `"In flight" (what's clearly underway), "Next steps" (3-5 concrete actions), "Ideas" (2-3 that fit where this is heading). ` +
85
- `Ground each point in the evidence; recent commits beat the README. Do not advise against the stated conventions. ` +
90
+ `"In flight" (what's clearly underway), "Next steps" (the 2 best concrete actions), "Ideas" (the 2 best that fit where this is heading). ` +
91
+ JUDGE + `Recent commits beat the README. Do not advise against the stated conventions. ` +
86
92
  (state.stale ? `CRITICAL: the working tree is a STALE checkout โ€” never suggest committing it (would revert history). ` : ``) +
87
93
  `No filler, no preamble.`;
88
94
  const prompt =
@@ -107,8 +113,8 @@ async function folderSuggest(path) {
107
113
  const system =
108
114
  `You are a pragmatic senior engineer looking at a project FOLDER that is NOT under version control. ` +
109
115
  `From its files, write exactly three short sections with headings: "Overview" (what this project is, 1-2 sentences), ` +
110
- `"Next steps" (3-5 concrete actions โ€” a strong first one is often "git init" if this looks like real work), ` +
111
- `"Ideas" (2-3 that fit where it's heading). Ground every point in the actual files/manifest/README shown. No preamble.`;
116
+ `"Next steps" (the 2 best concrete actions โ€” a strong first one is often "git init" if this looks like real work), ` +
117
+ `"Ideas" (the 2 best that fit where it's heading). ` + JUDGE + `The evidence here is the files, manifest and README shown. No preamble.`;
112
118
  const prompt =
113
119
  `Folder: ${det.name}\nStack: ${stack}\nFiles (${det.files}):\n${rels.slice(0, 120).join("\n")}\n\n` +
114
120
  (manifest ? `Manifest:\n${manifest}\n\n` : "") + (readme ? `README excerpt:\n${readme}\n\n` : "") + `Write the overview and suggestions.`;