symbiot 0.57.4 → 0.57.6

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/home.mjs CHANGED
@@ -12,22 +12,22 @@
12
12
  import { join } from "node:path";
13
13
  import { homedir } from "node:os";
14
14
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
- import { watchBoard, draftReply } from "./watch.mjs";
15
+ import { watchBoard, draftReply, draftCards, draftAnswer } from "./watch.mjs";
16
16
  import { pendingReview, pushTasks, addTask, taskType } from "./tasks.mjs";
17
17
  import { estimate, estimateWords } from "./estimate.mjs";
18
- import { agentsList, runHandoff, runningHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent, detectHandoffs, linkReach } from "./agents.mjs";
18
+ import { setHandoffCmd, agentsList, runHandoff, runningHandoff, handoffCmd, loadRuns, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent, detectHandoffs, linkReach } from "./agents.mjs";
19
19
  import { linksState } from "./links.mjs";
20
20
  import { knowledgeState } from "./knowledge.mjs";
21
- import { CONFIG_DIR, loadTasks, saveTasks, loadConfig, saveConfig } from "./core.mjs";
21
+ import { CONFIG_DIR, loadTasks, saveTasks, loadConfig, saveConfig, sameTask } from "./core.mjs";
22
22
  import { lanesState, stuckHandovers, allowHandover, skipHandover } from "./lanes.mjs";
23
23
  import { converse, actIn, actNow, taskIn, loadMind } from "./mind.mjs";
24
24
  import { repoPathMap, laneMap, reposState, scanRoots } from "./scan.mjs";
25
25
  import { resolveProvider, PROVIDERS, claudeState } from "./ai.mjs";
26
- import { reportsNews, listReports } from "./reports.mjs";
26
+ import { reportsNews, listReports, readReport, decide } from "./reports.mjs";
27
27
  import { awaitingState, inboxSight } from "./handback.mjs";
28
28
  import { newClashes } from "./checks.mjs";
29
29
  import { postsState } from "./post.mjs";
30
- import { MARKETING, MARKETING_DIR, MARKETING_WORDS, displayName, productNames, productOf, untagged, tagged, draftFiles } from "./marketing.mjs";
30
+ import { MARKETING, MARKETING_DIR, MARKETING_WORDS, displayName, productNames, productOf, untagged, tagged, draftFiles, setDraftStatus } from "./marketing.mjs";
31
31
 
32
32
  const KEEP = 15000; // the slower reads (git per repo awaiting review) are cached this long
33
33
  let cached = null;
@@ -47,8 +47,16 @@ const firstLine = (t) => String(t || "").split("\n").find((l) => l.trim()) || ""
47
47
  // a step your answer left you) with what to check first, and a run that ended in an
48
48
  // error. Each is answered on its blob (homeAnswer), on Home and on its lane in
49
49
  // Tasks. repo: its lane ("" for an ops run).
50
- function troubles({ stuck = [], list = [], map = {}, named = displayName, now = Date.now() } = {}) {
50
+ function troubles({ stuck = [], list = [], map = {}, named = displayName, now = Date.now(), drafts = [] } = {}) {
51
51
  const out = [], seen = new Set();
52
+ // a drafted reply: the reply itself on the card, as it will look, and Go ahead sends it (watch.mjs draftCard)
53
+ for (const c of drafts) {
54
+ seen.add(c.dir);
55
+ const to = c.to ? ` to ${c.to}` : "";
56
+ out.push({ kind: "ask", fix: "draft", id: "draft:" + c.id, path: c.dir, repo: "", name: `Reply${to} on ${c.platform}`, title: `Reply${to} on ${c.platform}`, sub: plain(c.text, 90),
57
+ q: `Go ahead sends it${to} from your ${c.platform}, signed in in Symbiot's browser, exactly as it shows here.`, options: ["Go ahead (recommended)", "Skip"],
58
+ draft: { platform: c.platform, kind: c.kind, to: c.to, text: c.text }, shape: "agents" });
59
+ }
52
60
  for (const t of stuck) {
53
61
  const lane = t.from.lane === RUNS_LANE ? "" : t.from.lane;
54
62
  out.push({ kind: "ask", fix: "handover", id: "stuck:" + t.id, path: lane ? map[lane] || t.from.path : t.from.path, repo: lane, name: lane ? named(map[lane], lane) : "Agent runs",
@@ -73,6 +81,41 @@ function troubles({ stuck = [], list = [], map = {}, named = displayName, now =
73
81
  return out;
74
82
  }
75
83
 
84
+ // Where your repos are is a fact on this disk, not a thing only you know: Symbiot
85
+ // finds them itself (scan.mjs, on start and every few minutes) and never asks for it.
86
+ // "Show me your work: where your repos are" asked anyway whenever the list was empty,
87
+ // and it was empty while the first search ran and whenever one was cut short. Now:
88
+ // once it has found them, you're asked once to confirm (or correct, in your own
89
+ // words: an agent fixes it); and for a folder only when a finished search found none.
90
+ // Folders you set in Settings (scanRoots) are your answer already: the list isn't
91
+ // asked about again, and a search that finds nothing in them is Symbiot's to retry
92
+ // (Settings says so under the folders), never a card asking where your work is.
93
+ function reposCard({ map = {}, search = {}, confirmed = false, rootsSet = false } = {}) {
94
+ const found = Object.keys(map).filter((n) => map[n] !== MARKETING_DIR);
95
+ if (found.length) {
96
+ if (confirmed || rootsSet) return [];
97
+ const list = found.slice(0, 8).join(", ") + (found.length > 8 ? ` and ${found.length - 8} more` : "");
98
+ return [{ kind: "ask", fix: "repos", id: "repos:confirm", path: "", repo: "", name: "Your projects", title: `I found ${found.length} project${found.length > 1 ? "s" : ""}`, sub: plain(list, 90),
99
+ q: `I found these on this computer: ${list}. Is that all of them? If one's missing or shouldn't be there, say which.`, options: ["Looks right (recommended)", "Skip"], shape: "settings" }];
100
+ }
101
+ if (search.searching || !search.at || rootsSet) return []; // still looking, or you've said where: nothing to ask
102
+ return [{ kind: "setup", id: "setup:folders", title: "No projects found", sub: "I searched your folders and found no repos: add the folder they're in", shape: "settings", focus: "work" }];
103
+ }
104
+ const SAYS_YES = /^\s*(yes|yep|yeah|ok|okay|looks (right|good)|correct|right|all good|that'?s (all|it|right))\b[\s.!]*$/i;
105
+ // Your answer on it: right (or skipped) settles it; anything else is a correction an
106
+ // agent makes (adds the folder, says why one is missing), not a chore for you.
107
+ function reposAnswer({ pick, text = "" } = {}, { act = actNow, repos = laneMap } = {}) {
108
+ const skip = /^\s*(skip|no|nope|never ?mind|leave it)[\s.!]*$/i.test(text); // a whole "no", not "no, ~/work is missing"
109
+ const fix = text && !SAYS_YES.test(text) && !skip;
110
+ const cfg = loadConfig(); cfg.reposConfirmed = { at: Date.now(), ...(fix ? { fix: text } : {}) }; saveConfig(cfg);
111
+ if (!fix) return { ok: true, said: Number(pick) === 1 || skip ? "Skipped. Change the folders any time in Settings." : "Good. New ones are picked up as they appear." };
112
+ let found = {}; try { found = repos() || {}; } catch {}
113
+ const list = Object.entries(found).filter(([, p]) => p !== MARKETING_DIR).map(([n, p]) => `- ${n}: ${p}`).join("\n");
114
+ const r = act(`Correct the list of projects Symbiot found on this computer. The user said: "${text}"`, { title: "fix the project list",
115
+ context: `Symbiot scans ${scanRoots().join(", ")} for git repos (scan folders: Settings, or scanRoots in ~/.config/symbiot/config.json). It found:\n${list}\nFind what the user means on disk yourself; don't ask them where things are.` });
116
+ return r.error ? { error: r.error } : { ok: true, said: "An agent is fixing the list from what you said." };
117
+ }
118
+
76
119
  // ---- next up: what can be done when nothing waits on you ----------------------------
77
120
  // With "Only the user can do" empty, Home suggests a few things that CAN be done next,
78
121
  // each with its gain spelled out and one tap that starts an agent or makes a task
@@ -203,11 +246,14 @@ function marketingState({ deps = {}, list, pend, map, named = displayName } = {}
203
246
  const ready = (pend || tryOr(deps.pending || pendingReview, []) || []).find((r) => r.repo === MARKETING && r.path && !r.running && ((r.tasks || []).length || (r.files || []).length));
204
247
  if (ready) { const n = (ready.tasks || []).length, f = (ready.files || []).length; needs.push({ kind: "approve", id: "approve", text: `${n ? `${n} piece${n > 1 ? "s" : ""} of marketing done` : "Marketing's agent changed things without a task"}${f ? ` · ${f} file${f > 1 ? "s" : ""}` : ""}: only you decide`, count: n || f }); }
205
248
  for (const p of (posts.posts || []).filter((x) => x.status === "waiting")) needs.push({ kind: "draft", id: "draft:" + p.id, text: plain(firstLine(p.text), 120), product: productOf(p.text, names) });
249
+ // a post its agent drafted that you haven't approved or skipped: approving its preview is
250
+ // the only step a post needs from you, so it's lit here (and on Home's orb), Preview one click away
251
+ for (const f of files.filter((x) => !x.status)) needs.push({ kind: "post", id: "post:" + f.rel, rel: f.rel, text: plain(f.name, 120), product: f.product });
206
252
  const lane = tasks.filter((t) => t.repo === MARKETING).map((t) => ({ id: t.id, text: plain(untagged(t.text), 220), product: productOf(t.text, names), status: t.review ? "review" : working ? "working" : "waiting", ts: t.ts || 0 }));
207
253
  const elsewhere = tasks.filter((t) => t.repo !== MARKETING && MARKETING_WORDS.test(t.text)).slice(0, 12).map((t) => ({ id: t.id, text: plain(untagged(t.text), 220), product: productOf(t.text, names), repo: t.repo || "", name: t.repo ? named(map[t.repo], t.repo) : "" }));
208
254
  const seen = [...new Set([...lane, ...elsewhere, ...needs, ...files].map((x) => x.product).filter(Boolean))];
209
255
  const on = !!(posts.canDraft || (posts.posts || []).length || (posts.done || []).length || lane.length || elsewhere.length || files.length || needs.length || run);
210
- return { on, needs, needCount: needs.length, lane, elsewhere, files: files.map(({ rel, name, product, at }) => ({ rel, name, product, at })), products: [...new Set([...seen, ...names])], working, waiting: lane.filter((t) => t.status === "waiting").length };
256
+ return { on, needs, needCount: needs.length, lane, elsewhere, files: files.map(({ rel, name, product, at, status }) => ({ rel, name, product, at, status })), products: [...new Set([...seen, ...names])], working, waiting: lane.filter((t) => t.status === "waiting").length };
211
257
  }
212
258
  // A task for Marketing, tagged with the product it markets (the page's Add). The task or { error }.
213
259
  function marketingTask(text, product = "", { add = addTask } = {}) {
@@ -224,6 +270,20 @@ function moveToMarketing(id, product = "", { load = loadTasks, save = saveTasks,
224
270
  t.repo = MARKETING; save(all); cached = null;
225
271
  return { ok: true, id: t.id };
226
272
  }
273
+ // Approve or Skip on a draft's preview: your only step in a post. Approved, its agent is
274
+ // told in ANSWERS.md to post it (or schedule it for its date) through Symbiot's signed-in
275
+ // browser with exactly the text you saw, and starts; skipped, it's told not to.
276
+ function marketingDraftAnswer(rel, status, deps = {}) {
277
+ const dir = deps.dir || MARKETING_DIR, r = (deps.set || setDraftStatus)(String(rel || ""), status, { dir });
278
+ if (r.error) return r;
279
+ cached = null;
280
+ const d = r.draft, when = d.when ? `, scheduled in ${d.platform}'s own scheduler for ${d.when}` : "";
281
+ if (status === "skipped") { noteAnswer(dir, `Skipped: ${rel}`, "Don't post it. Leave the draft where it is."); return { ok: true, said: "Skipped. Its agent won't post it." }; }
282
+ noteAnswer(dir, `Approved: ${rel}`, `The user approved its preview. Post it on ${d.platform}${when}, through Symbiot's signed-in browser, with exactly the text under its post (${d.body.length} characters, unchanged)${d.media.length ? ` and ${d.media.join(", ")} attached` : ""}. Then check it's there, and say so in your last message.`);
283
+ if ((deps.running || runningHandoff)(dir)) return { ok: true, said: "Approved. Its agent posts it once the run there now finishes." };
284
+ const e = (deps.run || runHandoff)(dir, { force: true });
285
+ return e && e.id && !e.blocked ? { ok: true, said: `Approved. Its agent is ${d.when ? "scheduling" : "posting"} it now.`, rerun: e.id } : { ok: true, said: `Approved. ${(e && e.note) || "Start its agent to post it."}` };
286
+ }
227
287
  // Start Marketing's agent on its waiting tasks (the page's Start its agent). { ok } or { error }.
228
288
  function marketingGo(deps = {}) {
229
289
  cached = null;
@@ -244,6 +304,8 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
244
304
  const reports = deps.reports || (() => { try { return reportsNews(); } catch { return { count: 0 }; } });
245
305
  const connected = deps.connected || (() => !!resolveProvider());
246
306
  const repos = deps.repos || (() => { try { return laneMap(); } catch { return {}; } });
307
+ const search = deps.search || (() => { try { return reposState(); } catch { return { searching: false, at: 0, list: [] }; } });
308
+ const confirmed = deps.confirmed || (() => { try { return !!loadConfig().reposConfirmed; } catch { return false; } });
247
309
  const waits = deps.waits || (() => { try { return awaitingState().waits; } catch { return []; } });
248
310
  const clashes = deps.clashes || (() => { try { return newClashes(); } catch { return []; } });
249
311
  // can a reply be noticed? Email on (mail on this computer), or an inbox watched and signed in
@@ -265,12 +327,16 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
265
327
  if (!connected()) you.push({ kind: "setup", id: "setup:ai", urgent: true, title: "Connect an AI", sub: "Symbiot can't work without one: your Claude subscription (sign in to Claude Code), a key, or a free local model", shape: "settings", focus: "ai" });
266
328
  const gone = agentGone(); if (gone) you.push({ kind: "setup", id: "setup:agent", urgent: true, title: "Your agent is unavailable", sub: `${gone} isn't on this computer any more, so no task can start`, shape: "settings", focus: "agent" });
267
329
  for (const c of signedOut()) you.push({ kind: "setup", id: "setup:conn:" + c.id, urgent: true, title: `Reconnect ${c.name}`, sub: `its Claude connector is signed out, so agent runs can't use ${c.name}`, shape: "settings", focus: "agent" });
268
- // Folders you set in Settings (scanRoots) are your answer already: a search that finds
269
- // nothing in them (or is cut short) is Symbiot's to retry, never a card asking again.
270
- if (!rootsSet() && !Object.keys(map).some((n) => map[n] !== MARKETING_DIR)) you.push({ kind: "setup", id: "setup:folders", title: "Show me your work", sub: "where your repos are", shape: "settings", focus: "work" });
330
+ you.push(...reposCard({ map, search: search(), confirmed: confirmed(), rootsSet: rootsSet() }));
271
331
  // no agent yet: Send to repos and Go would only write TASKS.md files nothing runs, so
272
- // Home didn't say "All handled" truthfully. One click for the one on this computer.
273
- if (!agentCmd()) { const p = offer(); you.push({ kind: "setup", id: "setup:pick", title: "Pick your agent", sub: p ? `${p.name} is on this computer: one click and it takes your tasks` : "the coding agent that takes your tasks", shape: "settings", focus: "agent", ...(p ? { pick: { name: p.name, tmpl: p.tmpl } } : {}) }); }
332
+ // Home didn't say "All handled" truthfully. Which agent is on this computer is a
333
+ // lookup, not your call: one found is set (change it in Settings), and you're asked
334
+ // only when there's none to set, or you cleared yours.
335
+ if (!agentCmd()) {
336
+ const p = offer(), off = deps.agentOff ? deps.agentOff() : (() => { try { return !!loadConfig().agentOff; } catch { return false; } })();
337
+ if (p && !off) (deps.setAgent || setHandoffCmd)(p.tmpl);
338
+ else you.push({ kind: "setup", id: "setup:pick", title: "Pick your agent", sub: p ? `${p.name} is on this computer: one click and it takes your tasks` : "no coding agent on this computer yet: install one (Claude Code, Codex, Gemini or Aider) and it takes your tasks", shape: "settings", focus: "agent", ...(p ? { pick: { name: p.name, tmpl: p.tmpl } } : {}) });
339
+ }
274
340
  const pend = pending();
275
341
  for (const r of pend) {
276
342
  if (!r.path || r.running) continue;
@@ -290,7 +356,8 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
290
356
  // What stopped and can't go on without you, as a question too: a handover that
291
357
  // couldn't start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"),
292
358
  // and a run that ended in an error. Answered on its blob (homeAnswer).
293
- you.push(...troubles({ stuck: stuck(), list, map, named, now }));
359
+ const drafts = deps.drafts || (() => { try { return draftCards(); } catch { return []; } });
360
+ you.push(...troubles({ stuck: stuck(), list, map, named, now, drafts: drafts() }));
294
361
  // A run is waiting on an emailed reply that nothing will see: say so, once,
295
362
  // until an inbox is watched (and signed in) or Email is on (handback.mjs looks in either).
296
363
  if (waits().some((w) => w.status === "waiting")) {
@@ -350,6 +417,9 @@ function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
350
417
  const e = (deps.run || runHandoff)(a.path, { force: true }); dropped.add(ref);
351
418
  return e && e.id && !e.blocked ? { ok: true, said: "Running it again.", rerun: e.id } : { error: (e && e.note) || "It didn't start. Check your agent in Settings." };
352
419
  }
420
+ if (id.startsWith("repos:")) return reposAnswer({ pick, text }, deps);
421
+ // a drafted reply: Go ahead sends it, Skip drops it, your own words are Change it (a redraft on the same card)
422
+ if (id.startsWith("draft:")) return (deps.draftAnswer || draftAnswer)(ref, text ? (SAYS_NO.test(text) ? { skip: true } : { change: text }) : go ? { go: true } : { skip: true });
353
423
  if (id.startsWith("needs:")) {
354
424
  const a = (deps.agents || agentsList)().find((x) => x.id === ref), n = a && a.needs;
355
425
  if (!n) return { error: "That isn't waiting on you any more." };
@@ -428,6 +498,50 @@ async function homeAsk(question, { ask, now = Date.now(), state, images = [] } =
428
498
  return { answer: r.reply, ...(r.did ? { did: r.did } : {}), steps: r.steps || [] };
429
499
  }
430
500
 
501
+ // ---- acting on a report, where you read it (reports.mjs reportIdeas) -------------------
502
+ // A report ends with ideas drawn from it, and a box to ask about it: you shouldn't have
503
+ // to copy its "Top 3 next" into Home's chat to act on it (2026-10-08).
504
+ const reportById = (id, deps = {}) => (deps.list || listReports)().find((r) => r.id === String(id || ""));
505
+ // The ideas you ticked, onto the Workdesk in the report's lane (an ops run's: the ops
506
+ // lane), each saying which report it came from. { ok, added, lane, said } or { error }.
507
+ function reportIdeasAdd(id, ideas, deps = {}) {
508
+ const r = reportById(id, deps); if (!r) return { error: "That report isn't there any more." };
509
+ const list = (Array.isArray(ideas) ? ideas : []).map((x) => String(x || "").trim()).filter(Boolean).slice(0, 8);
510
+ if (!list.length) return { error: "Tick an idea first." };
511
+ const lane = r.lane || RUNS_LANE, add = deps.add || addTask;
512
+ const added = list.map((t) => add(t, lane, { after: `(from the report "${r.title}", ${r.file})` })).filter((t) => t && !t.error);
513
+ cached = null;
514
+ return added.length ? { ok: true, added: added.length, lane, said: `${added.length === 1 ? "It's" : `${added.length} are`} on the Workdesk, in ${lane === RUNS_LANE ? "ops" : lane}.` } : { error: "Couldn't add them." };
515
+ }
516
+ // Ask about the report in place: the same mind as Home's chat, the report in front of it.
517
+ async function reportAsk(id, question, { ask, deps = {} } = {}) {
518
+ question = String(question || "").trim().slice(0, 2000); if (!question) return { error: "empty" };
519
+ const r = (deps.read || readReport)(id, { mark: false }); if (!r || r.error) return { error: (r && r.error) || "That report isn't there any more." };
520
+ if (!ask && !resolveProvider()) return { error: "not-connected" };
521
+ let map = {}; try { map = laneMap(); } catch {}
522
+ const lane = r.lane && r.lane !== RUNS_LANE && map[r.lane] ? r.lane : "";
523
+ const role = `Here they're reading a report an agent wrote, "${r.title}"${lane ? ` (${lane})` : ""}, under Reports. Answer about it from its text; a task or agent work it leads to goes to ${lane || "the lane it belongs to"} unless they say otherwise.`;
524
+ const res = await converse({ where: `Reports: ${r.title}`, role, context: `The report (${r.file}):\n${String(r.text || "").slice(0, 12000)}`, question, map, ...(ask ? { ask } : {}),
525
+ act: {
526
+ agent: (req, known, repo) => ((repo || lane) ? actIn(req, repo || lane, { map, known, title: "Reports" }) : actNow(req, { title: "Reports", known })),
527
+ task: (text, repo) => taskIn(text, repo || lane, { map }),
528
+ } });
529
+ return { answer: res.reply, ...(res.did ? { did: res.did } : {}), steps: res.steps || [] };
530
+ }
531
+ // Approve or Reject on a draft report: its agent is told in ANSWERS.md, and an approved
532
+ // one goes ahead (its agent starts, or takes it once the run there finishes).
533
+ function reportDraftAnswer(id, approve, deps = {}) {
534
+ const r = reportById(id, deps); if (!r) return { error: "That report isn't there any more." };
535
+ const rel = `.symbiot/${r.name}`;
536
+ (deps.decide || decide)(r, approve ? "approved" : "rejected");
537
+ cached = null;
538
+ if (!approve) { noteAnswer(r.folder, `Rejected: ${rel}`, `The user rejected the draft "${r.title}". Don't use or send it. If they said why, it's below.`); return { ok: true, said: "Rejected. Its agent won't use it." }; }
539
+ noteAnswer(r.folder, `Approved: ${rel}`, `The user approved the draft "${r.title}" as it reads in ${rel}. Go ahead with it, unchanged, and say what you did in your last message.`);
540
+ if ((deps.running || runningHandoff)(r.folder)) return { ok: true, said: "Approved. Its agent goes ahead once the run there now finishes." };
541
+ const e = (deps.run || runHandoff)(r.folder, { force: true });
542
+ return e && e.id && !e.blocked ? { ok: true, said: "Approved. Its agent is going ahead with it.", rerun: e.id } : { ok: true, said: `Approved. ${(e && e.note) || "Start its agent to go ahead."}` };
543
+ }
544
+
431
545
  // ---- the work scene: the liquid when you open Tasks or Agents ---------------------
432
546
  // Plain words for someone who doesn't read diffs: what each agent is doing now,
433
547
  // what's waiting its turn, what's done and waiting for your OK, and whether
@@ -448,7 +562,7 @@ function runNow(a, { now = Date.now(), est = estimate } = {}) {
448
562
  // first (a question, an Approve), then what's at work, ready, waiting, and the
449
563
  // most recent. Ops runs (act-…: no repo of their own) are one "Agent runs" lane,
450
564
  // not a sphere each with an id for a name.
451
- function workScene({ deps = {} } = {}) {
565
+ function workScene({ deps = {}, full = false } = {}) {
452
566
  const agents = deps.agents || (() => { try { return agentsList(); } catch { return []; } });
453
567
  const pending = deps.pending || (() => { try { return pendingReview(); } catch { return []; } });
454
568
  const tasks = deps.tasks || (() => { try { return loadTasks(); } catch { return []; } });
@@ -469,7 +583,10 @@ function workScene({ deps = {} } = {}) {
469
583
  }
470
584
  const ready = pending().filter((r) => r.path && !r.running && ((r.tasks || []).length || (r.files || []).length)).map((r) => ({ id: "ready:" + r.repo, repo: r.repo, count: (r.tasks || []).length }));
471
585
  const busy = new Set(running.map((r) => r.lane)), open = tasks().filter((t) => !t.done && !t.archived && !t.review && t.repo);
472
- const waiting = open.map((t) => ({ id: "task:" + t.id, text: plain(t.text, 160), repo: t.repo, busy: busy.has(t.repo), ...(isParked(t.repo) ? { parked: true } : {}) })); // the app shortens it for a tag
586
+ // why each one waits, in plain words: on it now, queued behind a run, waiting on you, blocked, or next
587
+ const held = {}; for (const a of AG) { if (a.status === "running") continue; const l = laneOf(a), nq = (a.ask && a.ask.questions && a.ask.questions.length) || 0; if (!held[l] && (nq || a.waiting || (a.needs && !a.needs.waiting))) held[l] = { path: a.path, approve: !nq && !a.waiting && a.needs && a.needs.kind === "approve" }; }
588
+ const why = waitWhy({ map, named, running, held, ready: new Set(ready.map((r) => r.repo)), isParked, deps });
589
+ const waiting = open.map((t) => ({ id: "task:" + t.id, text: plain(t.text, 160), repo: t.repo, busy: busy.has(t.repo), ...(isParked(t.repo) ? { parked: true } : {}), ts: Number(t.ts) || 0, why: why(t) })); // the app shortens it for a tag
473
590
  // Projects: each lane with work on it, what's going on there in one line's worth
474
591
  const by = {}, at = (name) => (by[name] = by[name] || { repo: name, name: name === RUNS_LANE ? "Agent runs" : named(map[name], name), waiting: 0, ready: 0, asks: 0, running: null, last: 0, ...(name === RUNS_LANE ? { runs: 0 } : {}) });
475
592
  running.forEach((r) => { const p = at(r.lane); if (r.lane === RUNS_LANE) p.runs++; if (!p.running) p.running = { doing: r.doing, progress: r.progress, ask: r.waiting, ...(r.line ? { line: r.line } : {}), ...(r.eta ? { eta: r.eta } : {}), ...(r.quiet ? { quiet: r.quiet } : {}), ...(r.looping ? { looping: true } : {}) }; p.last = Math.max(p.last, r.started || 0); });
@@ -495,25 +612,109 @@ function workScene({ deps = {} } = {}) {
495
612
  for (const s of ideas) { p.ideas = p.ideas || []; if (p.ideas.length < 4) p.ideas.push({ text: plain(s.text, 200), full: String(s.text), repo: s.repo || l, path: a.path }); }
496
613
  }
497
614
  const stuck = deps.stuck || (() => { try { return stuckHandovers(); } catch { return []; } });
498
- for (const t of troubles({ stuck: stuck(), list: AG, map, named })) {
615
+ const drafts = deps.drafts || (() => { try { return draftCards(); } catch { return []; } });
616
+ for (const t of troubles({ stuck: stuck(), list: AG, map, named, drafts: drafts() })) {
499
617
  const p = at(t.repo || RUNS_LANE); p.asks++; p.qs = p.qs || [];
500
- if (p.qs.length < 3) p.qs.unshift({ q: t.q, options: t.options, path: t.path, fix: t.fix, id: t.id, sub: t.sub });
618
+ if (p.qs.length < 3) p.qs.unshift({ q: t.q, options: t.options, path: t.path, fix: t.fix, id: t.id, sub: t.sub, ...(t.draft ? { draft: t.draft } : {}) });
501
619
  }
502
620
  for (const p of Object.values(by)) { p.lit = !!(p.asks || p.ready); if (isParked(p.repo)) p.parked = true; }
503
621
  const projects = Object.values(by).sort((a, b) => (b.lit ? 1 : 0) - (a.lit ? 1 : 0) || (a.parked ? 1 : 0) - (b.parked ? 1 : 0) || (b.running ? 1 : 0) - (a.running ? 1 : 0) || b.ready - a.ready || b.waiting - a.waiting || b.last - a.last || (a.repo < b.repo ? -1 : 1));
504
- return { running, ready, waiting: waiting.slice(0, 12), waitingCount: waiting.length, canGo: waiting.filter((w) => !w.busy && !w.parked).length, projects: projects.slice(0, 24), projectCount: projects.length };
622
+ const stuckN = waiting.filter((w) => w.why.stuck).length;
623
+ return { running, ready, waiting: waiting.slice(0, 12), ...(full ? { all: waiting } : {}), waitingCount: waiting.length, canGo: waiting.filter((w) => !w.busy && !w.parked && !w.why.noGo).length, ...(stuckN ? { stuckCount: stuckN } : {}), projects: projects.slice(0, 24), projectCount: projects.length };
624
+ }
625
+ // ---- why a task waits ---------------------------------------------------------------
626
+ // Home showed "7 waiting" and nothing said why or moved them (2026-10-08): an ops task
627
+ // ("🤖 Agent: free space on /mnt/storage") had no folder, so Go passed it over, and a
628
+ // repo's tasks sat until someone pressed Go. Each waiting task now says why, in a
629
+ // line: its lane's agent is on it now; it's queued behind the run in its lane (named);
630
+ // it waits on your answer or your OK (its Workdesk card: `path`); it's blocked (no
631
+ // folder, no agent, parked, the start failed); or it's next, and starts by itself
632
+ // (workTick). One whose lane is free and has waited past STUCK_AFTER, or whose run
633
+ // ended without ticking it, is flagged (`stuck`) rather than started again and again.
634
+ const AUTO_GRACE = 30000, STUCK_AFTER = 5 * 60000, RETRY_AFTER = 5 * 60000, AUTO_FAIL = {}; // lane → { at, note }: a start that didn't take
635
+ const yours = (text) => /^\s*👤/.test(String(text || "")); // "👤 You (only you: …)": the user's step, never an agent's
636
+ const asAgent = (text) => String(text || "").replace(/^\s*🤖\s*/u, "").replace(/^Agent:\s*/i, "").trim();
637
+ const minutes = (ms) => { const m = Math.round(ms / 60000); return m >= 120 ? `${Math.round(m / 60)} hours` : `${m} min`; };
638
+ // What a folder's agent was last handed (agents.mjs handed.json) and when its last run started.
639
+ function briefOf(path) { try { const h = JSON.parse(readFileSync(join(path, ".symbiot", "handed.json"), "utf8")); return { at: Number(h.at) || 0, tasks: Array.isArray(h.tasks) ? h.tasks : [] }; } catch { return { at: 0, tasks: [] }; } }
640
+ function lastRunAt(path) { const r = loadRuns().find((x) => x.path === path); return (r && Number(r.startedAt)) || 0; }
641
+ function waitWhy({ map, named, running, held, ready, isParked, deps = {} }) {
642
+ const brief = deps.brief || briefOf, lastRun = deps.lastRun || lastRunAt, now = deps.now || Date.now();
643
+ const agentSet = !!(deps.cmd || handoffCmd)(), fails = deps.fails || AUTO_FAIL;
644
+ return (t) => {
645
+ const lane = t.repo, name = lane === RUNS_LANE ? "ops" : named(map[lane], lane), age = now - (Number(t.ts) || now);
646
+ const flag = (text) => ({ kind: "stuck", stuck: true, text });
647
+ if (yours(t.text)) return { kind: "you", noGo: true, text: "Yours to do: no agent can, so nothing starts it" };
648
+ if (isParked(lane)) return { kind: "parked", text: `Blocked: ${name} is parked, so nothing starts there until you unpark it` };
649
+ if (!agentSet) return { kind: "blocked", text: "Blocked: no coding agent is set (Settings → Handoff)" };
650
+ const fail = fails[lane];
651
+ if (lane === RUNS_LANE) { // each gets an agent run of its own: never queued behind another
652
+ if (fail) return { kind: "blocked", failAt: fail.at, text: `Blocked: it didn't start (${fail.note})` };
653
+ return age > STUCK_AFTER ? flag(`Not started after ${minutes(age)}, with nothing in its way`) : { kind: "next", text: "Next: starts as an agent run of its own" };
654
+ }
655
+ const path = map[lane]; if (!path) return { kind: "blocked", noGo: true, text: `Blocked: Symbiot can't find ${name}'s folder` };
656
+ const b = brief(path), inBrief = b.tasks.some((x) => sameTask(x, t.text)), run = running.find((r) => r.lane === lane);
657
+ if (run) return inBrief ? { kind: "working", text: `${name}'s agent is on it now` } : { kind: "queued", text: `Queued behind ${name}'s run: ${plain(run.doing, 60).toLowerCase()}`, run: run.doing };
658
+ const h = held[lane];
659
+ if (h) return h.approve ? { kind: "approve", noGo: true, text: `Waiting on your OK for ${name}'s last run, on the Workdesk`, path: h.path } : { kind: "asks", noGo: true, text: `Waiting on your answer to ${name}'s question, on the Workdesk`, path: h.path };
660
+ if (inBrief && ready.has(lane)) return { kind: "approve", noGo: true, text: `Waiting on your OK for ${name}'s work, on the Workdesk`, path };
661
+ if (fail) return { kind: "blocked", failAt: fail.at, text: `Blocked: ${name}'s agent didn't start (${fail.note})` };
662
+ if (inBrief && lastRun(path) >= b.at) return { ...flag(`${name}'s last run ended without finishing it: Go starts it again`), ended: true };
663
+ return age > STUCK_AFTER ? flag(`Not started after ${minutes(age)}, with nothing in its way`) : { kind: "next", text: `Next: starts on its own, ${name}'s lane is free` };
664
+ };
665
+ }
666
+ // Start what's next by itself, every 20 seconds (server.mjs): a lane's waiting tasks go
667
+ // to its agent once it's free (lanes in parallel, one run per lane), and an ops task
668
+ // ("🤖 Agent: …") gets an agent run of its own, each in parallel, and leaves the list
669
+ // (its run is on the Workdesk). A task gets AUTO_GRACE first, so a few added together
670
+ // go out in one brief. A start that didn't take is kept and shown as the block; the
671
+ // next tick tries again. config.autoStart: false leaves it all to Go.
672
+ // { started: [lanes], ops: [task ids], failed: [lanes] }
673
+ function workTick({ deps = {} } = {}) {
674
+ const out = { started: [], ops: [], failed: [] };
675
+ if ((deps.config || loadConfig)().autoStart === false || !(deps.cmd || handoffCmd)()) return out;
676
+ const now = deps.now || Date.now(), fails = deps.fails || AUTO_FAIL, push = deps.push || pushTasks, run = deps.run || runHandoff;
677
+ const w = workScene({ deps: { ...deps, fails, now }, full: true }), lanes = new Set(), ops = [];
678
+ for (const x of w.all) {
679
+ if (x.why.kind !== "next" && !(x.why.stuck && !x.why.ended) && !(x.why.failAt && now - x.why.failAt >= RETRY_AFTER)) continue; // a failed start: tried again every few minutes
680
+ if (now - (x.ts || 0) < AUTO_GRACE) continue;
681
+ if (x.repo === RUNS_LANE) ops.push(x.id.slice(5)); else lanes.add(x.repo);
682
+ }
683
+ Object.assign(out, startOps(ops, { ...deps, fails, now }));
684
+ for (const lane of lanes) {
685
+ const p = push({ repo: lane }), wr = p && p.written && p.written[0]; if (!wr) continue;
686
+ const e = run(wr.path);
687
+ if (e && e.id && !e.busy && !e.blocked) { delete fails[lane]; out.started.push(lane); }
688
+ else if (!e || (e.blocked && !e.parked)) { fails[lane] = { at: now, note: plain((e && e.note) || "the agent command didn't run", 120) }; out.failed.push(lane); }
689
+ }
690
+ return out;
691
+ }
692
+ // Ops tasks (by id) each to an agent run of its own; one that started leaves the list
693
+ // (done, its run's folder in `act`). { ops: [ids started], failed: ["ops"]? }
694
+ function startOps(ids, { tasks = loadTasks, save = saveTasks, act = actNow, fails = AUTO_FAIL, now = Date.now() } = {}) {
695
+ const out = { ops: [], failed: [] };
696
+ for (const id of ids) {
697
+ const all = tasks(), t = all.find((k) => k.id === id); if (!t) continue;
698
+ const r = act(asAgent(t.full || t.text), { title: "from your tasks" });
699
+ if (r && r.ok) { delete fails[RUNS_LANE]; Object.assign(t, { done: true, doneAt: now, act: r.dir }); save(all); out.ops.push(id); }
700
+ else { fails[RUNS_LANE] = { at: now, note: plain((r && r.error) || "the agent didn't start", 120) }; out.failed = [RUNS_LANE]; break; }
701
+ }
702
+ return out;
505
703
  }
506
704
  // Go: everything waiting goes to its repo's agent, the way Send to repos and an
507
705
  // agent per repo would: briefs written, an agent started in each (or queued
508
706
  // behind one already there); a parked project's wait for it to be unparked.
509
707
  // { started, queued, repos, parked? } or { error }.
510
- function workGo({ push = pushTasks, run = runHandoff } = {}) {
511
- const r = push({});
512
- if (r.empty) return { started: 0, queued: 0, repos: [], note: "Nothing waiting to start." };
513
- let started = 0, queued = 0; const repos = [], parked = [];
708
+ function workGo({ push = pushTasks, run = runHandoff, ops = goOps } = {}) {
709
+ const o = ops(), r = push({});
710
+ if (r.empty && !o.ops.length && !o.failed.length) return { started: 0, queued: 0, repos: [], note: "Nothing waiting to start." };
711
+ let started = o.ops.length, queued = 0; const repos = o.ops.length ? ["ops"] : [], parked = [];
514
712
  for (const w of r.written || []) { const e = run(w.path); if (e && e.parked) { parked.push(w.name); continue; } if (e && e.id && !e.busy && !e.blocked) started++; else queued++; repos.push(w.name); }
515
- return { started, queued, repos, ...(parked.length ? { parked } : {}), ...(r.unresolved && r.unresolved.length ? { unresolved: r.unresolved.map((u) => u.name) } : {}) };
713
+ const unresolved = (r.unresolved || []).map((u) => u.name).filter((n) => n !== RUNS_LANE);
714
+ return { started, queued, repos, ...(parked.length ? { parked } : {}), ...(unresolved.length ? { unresolved } : {}), ...(o.failed.length ? { note: `The ops tasks didn't start: ${AUTO_FAIL[RUNS_LANE].note}` } : {}) };
516
715
  }
716
+ // Go's ops tasks: every one an agent can do, each to a run of its own.
717
+ const goOps = () => startOps(loadTasks().filter((t) => t.repo === RUNS_LANE && !t.done && !t.archived && !t.review && !yours(t.text)).map((t) => t.id));
517
718
 
518
719
  // ---- setup (the first run) --------------------------------------------------------
519
720
  // A new Symbiot walks you through setup before anything else: what it is, your AI,
@@ -534,7 +735,7 @@ function onboarding({ fresh = false } = {}) {
534
735
  return {
535
736
  pending: !!o.pending, step: ONB_STEPS.includes(o.step) ? o.step : "welcome", steps: ONB_STEPS,
536
737
  ai: { connected: !!r, provider: r ? r.provider : "", line: r ? `${PROVIDERS[r.provider].label}${r.model ? " · " + r.model : ""}` : "", claude: st ? { installed: st.installed, signedIn: st.signedIn } : null },
537
- work: { searching: reps.searching, done: reps.done || 0, total: reps.total || 0, count: reps.list.length, repos: reps.list.slice(0, 60).map((x) => ({ name: x.name, path: x.path })), roots: tryOr(() => scanRoots(), []).map((r) => (r === homedir() ? "~" : r.startsWith(homedir() + "/") ? "~" + r.slice(homedir().length) : r)) },
738
+ work: { searching: reps.searching, at: reps.at || 0, done: reps.done || 0, total: reps.total || 0, count: reps.list.length, repos: reps.list.slice(0, 60).map((x) => ({ name: x.name, path: x.path })), roots: tryOr(() => scanRoots(), []).map((r) => (r === homedir() ? "~" : r.startsWith(homedir() + "/") ? "~" + r.slice(homedir().length) : r)) },
538
739
  agent: { cmd, pick: tryOr(() => pickAgent(), null), agents: (det.agents || []).map((a) => ({ label: a.label, tmpl: a.tmpl })) },
539
740
  apps, groups: links.groups || [], decided: apps.every((a) => a.skipped || a.state !== "off"),
540
741
  docs: { folders: tryOr(() => (knowledgeState().folders || []).map((f) => f.path), []) },
@@ -553,4 +754,4 @@ function setOnboarding({ step, skip, unskip, skipRest, done, restart } = {}) {
553
754
  return onboarding();
554
755
  }
555
756
 
556
- export { marketingState, marketingGo, marketingTask, moveToMarketing, goLane, homeState, homeContext, homeAsk, homeAnswer, homeNext, nextUp, laneNamed, NEXT_FILE, workScene, workGo, displayName, firstSteps, onboarding, setOnboarding, ONB_STEPS };
757
+ export { reportIdeasAdd, reportAsk, reportDraftAnswer, workTick, waitWhy, AUTO_GRACE, STUCK_AFTER, marketingState, marketingGo, marketingDraftAnswer, marketingTask, moveToMarketing, goLane, homeState, homeContext, homeAsk, homeAnswer, homeNext, nextUp, laneNamed, NEXT_FILE, workScene, workGo, displayName, firstSteps, onboarding, setOnboarding, ONB_STEPS };
package/index.mjs CHANGED
@@ -41,7 +41,7 @@ import { loadScreens, screenImage, blueprint } from "./screens.mjs";
41
41
  import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, isTrusted } from "./headless.mjs";
42
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
- import { KIND_LABEL as POST_KIND, PATHS as POST_PATHS, draftPosts, postsState, postLog, approvePost, editPost, skipPost, voiceFromLinkedIn, openUrl, addMedia as addPostMedia, removeMedia as removePostMedia, mediaDir as postMediaDir, pictureOfPage, clipOfPage } from "./post.mjs";
44
+ import { KIND_LABEL as POST_KIND, PATHS as POST_PATHS, draftPosts, postsState, postLog, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia as addPostMedia, removeMedia as removePostMedia, mediaDir as postMediaDir, pictureOfPage, clipOfPage } from "./post.mjs";
45
45
  import { startApp, updateCmd, isAppRunningWeekly, askRunningApp, openApp } from "./server.mjs";
46
46
  import { listReports, readReport } from "./reports.mjs";
47
47
  import { runSandbox } from "./sandbox.mjs";
@@ -678,8 +678,8 @@ async function cmdPhone() {
678
678
  }
679
679
 
680
680
  // ---- `symbiot post`: the week's real work as 3 draft posts you approve ----------
681
- // Nothing is ever posted by Symbiot: approve copies one post to your clipboard and
682
- // gives LinkedIn's share box, where you paste it and post it yourself.
681
+ // Nothing is posted until you approve one: then Marketing's agent posts it, through
682
+ // Symbiot's browser signed in to LinkedIn.
683
683
  async function cmdPost() {
684
684
  const [sub = "draft", a1, a2] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--since" && all[i - 1] !== "--seconds");
685
685
  const fail = (msg) => { console.log(c.y(msg)); process.exitCode = 1; };
@@ -710,11 +710,8 @@ async function cmdPost() {
710
710
  const r = approvePost(a1);
711
711
  if (r.error) return fail(r.error);
712
712
  console.log(`${c.g("✓")} Approved.\n\n${r.post.text}\n`);
713
- console.log(r.copied ? `${c.g("✓")} Copied to your clipboard (${r.copied}).` : c.y("No clipboard tool found (wl-copy, xclip, xsel, pbcopy or clip): copy it from above."));
714
- console.log(`Paste it into LinkedIn's share box: ${c.b(r.share)}` + (has("open") ? "" : c.d(" (--open opens it)")));
715
- if (r.files.length) console.log(`Then add ${r.files.length === 1 ? "this" : "these"} with its photo or video button:\n${r.files.map((f) => " " + f).join("\n")}` + (has("open") ? "" : c.d(" (--open opens their folder)")));
716
- if (has("open")) { openUrl(r.share); if (r.folder) openUrl(r.folder); }
717
- console.log(c.d(r.note));
713
+ console.log(r.handed.error ? c.y(r.note) : r.note);
714
+ if (r.handed.error) process.exitCode = 1;
718
715
  return;
719
716
  }
720
717
  // pictures and video on a draft: a file of yours, or a picture or clip of a page
@@ -768,9 +765,8 @@ async function cmdPost() {
768
765
  fixed, a longer one) from your last 7 days of
769
766
  commits, release tags and CHANGELOG.md
770
767
  symbiot post list the drafts waiting on you
771
- symbiot post approve <id> [--open]
772
- copy it to your clipboard, and give (--open:
773
- open) LinkedIn's share box to paste it into
768
+ symbiot post approve <id> approve it: Marketing's agent posts it on
769
+ LinkedIn through Symbiot's signed-in browser
774
770
  symbiot post edit <id> "text" change a draft's words
775
771
  symbiot post add <id> <file> put a picture or video of yours on a draft
776
772
  (PNG, JPG, GIF, MP4, MOV, WebM)
package/lanes.mjs CHANGED
@@ -21,9 +21,9 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, chmodS
21
21
  import { createHash, randomBytes } from "node:crypto";
22
22
  import { execFile } from "node:child_process";
23
23
  import { CONFIG_DIR, loadTasks, clipWords } from "./core.mjs";
24
- import { runHandoff, runningHandoff, waitingFor, agentQuestions, findOrcaCli, installAllowlist } from "./agents.mjs";
25
- import { addTask, pushTasks } from "./tasks.mjs";
26
- import { actNow } from "./mind.mjs";
24
+ import { runHandoff, runningHandoff, waitingFor, agentQuestions, findOrcaCli, installAllowlist, writeTasks } from "./agents.mjs";
25
+ import { addTask, removeTask, pushTasks } from "./tasks.mjs";
26
+ import { actNow, actBrief } from "./mind.mjs";
27
27
  import { laneMap } from "./scan.mjs";
28
28
  import { parseRun, lastRunText, readRunLog } from "./work.mjs";
29
29
  import { OPS, parseHandoffs } from "./handover.mjs";
@@ -51,15 +51,56 @@ function laneTarget(lane, map) {
51
51
  return name ? { lane: name, path: map[name] } : null;
52
52
  }
53
53
 
54
+ // A handover whose text changed while the run doing the earlier version is still
55
+ // going (the LinkedIn auto-poster spec reached ops twice, on top of a run already
56
+ // building it) supersedes that run rather than starting a second one: the same
57
+ // ledger row takes the new text, and the lane's agent gets it once it's free.
58
+ // Ops: the new brief waits in that run's folder (writeTasks holds it while it
59
+ // runs; it lands and starts there, resuming the same conversation, when it exits),
60
+ // with a note in its ANSWERS.md saying what changed. A repo: the earlier task
61
+ // gives way to the new one. The earlier row: same asker, same lane, not reported
62
+ // back, about the same thing (alike), and its run still going (ops) or its task
63
+ // still open (a repo). null when there's none.
64
+ function supersedes(h, from, ledger, { running, tasks }) {
65
+ return ledger.handoffs.slice().reverse().find((x) => x.from.path === from.path && x.to.lane.toLowerCase() === h.lane.toLowerCase() && !x.reportedAt && (x.status === "started" || x.status === "held") && x.text !== h.text && alike(x.text, h.text)
66
+ && (x.to.lane === OPS ? !!x.to.path && !!running(x.to.path) : !!x.task && tasks().some((t) => t.id === x.task && !t.done && !t.archived && !t.review))) || null;
67
+ }
68
+ function resupply(e, text, { now, title, context }) {
69
+ const p = e.to.path;
70
+ try {
71
+ writeTasks(p, actBrief(text, { title, context, now }));
72
+ const file = join(p, ".symbiot", "ANSWERS.md"), had = existsSync(file) ? readFileSync(file, "utf8") : "# Answers\n";
73
+ writeFileSync(file, `${had.replace(/\s*$/, "")}\n\n### The handover changed while you ran\n${e.from.lane} rewrote what it handed over: TASKS.md has the new version, and it replaces the one you started on. Carry on from what you've done, to the new version.\n_symbiot (handover) ${new Date(now).toISOString().slice(0, 10)}_\n`);
74
+ return { ok: true };
75
+ } catch (err) { return { error: "Couldn't hand the new version to the run already going: " + ((err && err.message) || err) }; }
76
+ }
77
+
54
78
  // Pick up what a folder's agent handed over and start the lanes it named.
55
79
  // Each handover is started once (its key: where from, which lane, what).
56
- function dispatch(path, { map = laneMap(), act = actNow, run = runHandoff, add = addTask, push = pushTasks, now = Date.now(), ledger = loadLedger() } = {}) {
80
+ function dispatch(path, { map = laneMap(), act = actNow, run = runHandoff, add = addTask, push = pushTasks, drop = removeTask, running = runningHandoff, refresh = resupply, tasks = loadTasks, now = Date.now(), ledger = loadLedger() } = {}) {
57
81
  const asked = parseHandoffs(readSym(path, "HANDOFF.md")); if (!asked.length) return [];
58
82
  const from = { lane: laneOf(path, map), path }, parent = ledger.handoffs.filter((h) => h.to.path === path).pop(), chain = (parent ? parent.chain : 0) + 1;
59
83
  const started = [];
60
84
  for (const h of asked) {
61
85
  const key = createHash("sha1").update(path + "\n" + h.lane.toLowerCase() + "\n" + h.text).digest("hex").slice(0, 16);
62
- if (ledger.handoffs.some((x) => x.key === key)) continue;
86
+ if (ledger.handoffs.some((x) => x.key === key || (x.was || []).includes(key))) continue;
87
+ const prior = supersedes(h, from, ledger, { running, tasks });
88
+ if (prior) {
89
+ const was = [...(prior.was || []), prior.key];
90
+ if (prior.to.lane === OPS) {
91
+ const r = refresh(prior, h.text, { now, title: `handed over by ${from.lane}`, context: `${from.lane}'s agent (in ${path}) handed this over, and carries on once it's done.` });
92
+ if (r.error) { Object.assign(prior, { status: "error", error: r.error, text: h.text, key, was }); started.push(prior); continue; }
93
+ Object.assign(prior, { text: h.text, key, was, at: now, superseded: (prior.superseded || 0) + 1 });
94
+ } else {
95
+ drop(prior.task);
96
+ const t = add(h.text, prior.to.lane, { after: `(handed over by ${from.lane})` });
97
+ if (t.error) { Object.assign(prior, { status: "error", error: t.error, text: h.text, key, was }); started.push(prior); continue; }
98
+ push({ repo: prior.to.lane });
99
+ const r = run(prior.to.path) || {};
100
+ Object.assign(prior, { text: h.text, key, was, at: now, task: t.id, superseded: (prior.superseded || 0) + 1, status: r.id && !r.busy && !r.blocked ? "started" : "held", ...(r.id ? { job: r.id } : {}) });
101
+ }
102
+ started.push(prior); continue;
103
+ }
63
104
  const e = { id: randomBytes(4).toString("hex"), key, from, to: { lane: h.lane, path: "" }, text: h.text, at: now, chain, status: "started" };
64
105
  const to = laneTarget(h.lane, map);
65
106
  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.` });
@@ -155,9 +196,11 @@ function lanesTick({ map = laneMap(), act = actNow, run = runHandoff, running =
155
196
  const ledger = loadLedger();
156
197
  let acts = []; try { acts = readdirSync(ACT_DIR).filter((d) => d.startsWith("act-")).map((d) => join(ACT_DIR, d)); } catch {}
157
198
  const started = [];
158
- 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 }));
199
+ for (const path of [...Object.values(map), ...acts]) if (existsSync(join(path, ".symbiot", "HANDOFF.md"))) started.push(...dispatch(path, { map, act, run, add, push, running, now, ledger, ...(tasks ? { tasks: () => tasks } : {}) }));
159
200
  const t = tasks || loadTasks(), reported = [];
160
201
  for (const e of ledger.handoffs) {
202
+ // a superseded ops run that ended where nothing started its new brief (another process ran it): start it here
203
+ if (e.superseded && !e.reportedAt && e.status !== "error" && e.to.lane === OPS && e.to.path && !running(e.to.path) && lastRunStart(e.to.path) < e.at) { const r = run(e.to.path, { force: true }) || {}; if (r.id && !r.busy) e.job = r.id; }
161
204
  if (e.reportedAt || running(e.from.path)) continue; // the one that asked reads it when it's started again, so not mid-run
162
205
  const o = outcome(e, { tasks: t, running }); if (!o) continue;
163
206
  if (report(e, o, { run, running, now })) { e.reportedAt = now; if (e.status !== "error") e.status = "done"; e.result = o.text; reported.push(e); }