symbiot 0.54.3 → 0.54.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
4
4
 
5
+ ## 0.54.4 — 2026-10-07
6
+
7
+ - Removed the "Add a task" row from Tasks, since you add tasks through the bottom chat bar, and moved Send to repos beside Close.
8
+ - Fixed agent runs that wait on you, such as unsent drafts or steps only you can take, so they now appear on Home with what to check first.
9
+ - Moved the Reports Refresh button so it sits beside Close instead of under it.
10
+ - Added a Marketing section with post drafts, replies marked "maybe a customer", picture tools and the 4-week table, moved off the Dashboard.
11
+ - Reviewed where sandboxing for agent runs stands, with no changes to the app.
12
+ - Added marks on Connections showing which sites your agents can also use, visible before you link them.
13
+ - Changed the Dashboard to lead with what you watch; posts now appear under Marketing once drafting is possible.
14
+ - Fixed Home's right-side orbs overlapping zone names on screens 1366–1440 pixels wide.
15
+ - Added a "Pick your agent" step on first run that sets up the detected agent, such as Claude Code, in one click.
16
+
5
17
  ## 0.54.3 — 2026-10-07
6
18
 
7
19
  - Added questions on Home for stuck or failed agent runs, so you can allow access, skip, or run them again right there.
package/README.md CHANGED
@@ -93,8 +93,10 @@ It opens on [the liquid](#the-liquid-a-home-that-shapes-itself-to-you); each of
93
93
  a "fetch latest" toggle (and an [experimental](#experimental) "check CI").
94
94
  - **Week / Standup / Todo** — the write-ups (these use your chosen AI), set as a
95
95
  readable page with its dates; **Copy** copies the text as written.
96
- - **Tasks** — a checklist, grouped by kind (Fixes, Tests & CI, Docs, …). Filter by
97
- type or repo, then **Send to repos** to hand just those to your agent (see
96
+ - **Tasks** — a checklist, grouped by kind (Fixes, Tests & CI, Docs, …). You add
97
+ a task by telling Symbiot in the bar at the bottom: it files it under the right
98
+ project. Filter by type or repo, then **Send to repos** (at the top, beside
99
+ Close) to hand just those to your agent (see
98
100
  [`symbiot push`](#hand-tasks-to-your-coding-agent-symbiot-push)). What your agent
99
101
  finishes lands in **Awaiting your review** (see
100
102
  [Review and approve](#review-and-approve-the-agents-work)). A task you tick
@@ -685,14 +687,15 @@ release tags and dated `CHANGELOG.md` sections in the repos Symbiot already scan
685
687
  and nothing else. **It never posts by itself**, and it doesn't schedule.
686
688
 
687
689
  - **Your voice.** Put 5–10 posts you wrote in `~/.config/symbiot/voice.md`, with a
688
- line of `---` between each. Or link LinkedIn (above) and click **Fill from
689
- LinkedIn** on the Dashboard (`symbiot post voice --linkedin`), which reads your
690
+ line of `---` between each. Or link LinkedIn (above, or **Link LinkedIn** under
691
+ **Marketing**) and click **Fill from LinkedIn** there (`symbiot post voice --linkedin`), which reads your
690
692
  recent posts in Symbiot's signed-in browser. Without examples it doesn't draft.
691
693
  - **Only what git shows.** Your AI gets the week as numbered facts and must say
692
694
  which ones each post uses. A post that names a number, version, name or user
693
695
  count the facts don't show is sent back once, then dropped. When every draft is
694
696
  dropped, Symbiot tries once more from the start by itself before giving up.
695
- - **You approve each one.** The drafts wait on the **Dashboard** with **Approve**,
697
+ - **You approve each one.** The drafts wait under **Marketing**, a section of its
698
+ own (its orb shows on Home once posts can be drafted), with **Approve**,
696
699
  **Edit** and **Skip**. Approve copies the post to your clipboard and opens
697
700
  LinkedIn's share box: you paste it and post it yourself.
698
701
  - **Pictures and videos.** Each draft says in a line what picture or short video
@@ -709,10 +712,15 @@ and nothing else. **It never posts by itself**, and it doesn't schedule.
709
712
  - **Everything is logged** in `~/.config/symbiot/posts-log.jsonl`: each draft,
710
713
  edit, approval and skip, with its text, date and platform.
711
714
  - **Replies.** Linked LinkedIn is watched like an inbox: new comments and mentions
712
- show on its Dashboard card, marked **maybe a customer** when they ask how to
713
- install it, what it costs or about team use. **Draft a reply** has your agent
715
+ show under **Marketing** (and on its Dashboard card), marked **maybe a customer**
716
+ when they ask how to install it, what it costs or about team use. **Draft a reply** has your agent
714
717
  type a reply into the comment box without posting it (it can't press Post,
715
718
  Comment, Reply or Send), and write it in its brief for you to paste.
719
+ - **The 4-week test.** Marketing keeps a row a week: posts published (and how
720
+ many had a picture), replies, profile visits (LinkedIn's own count), installs
721
+ (npm downloads of the packages your posts are about) and pricing questions.
722
+ Week 1 is the first week a post goes out; to move it, put
723
+ `{ "start": "2026-10-13" }` in `~/.config/symbiot/post-test.json`.
716
724
 
717
725
  ```bash
718
726
  symbiot post # draft this week's 3 posts
package/adapt.mjs CHANGED
@@ -48,7 +48,7 @@ const MIN_EVENTS = 12; // under this many uses, adaptation fad
48
48
  const HICK_COVER = 0.9, HICK_MIN = 3, HICK_MAX = 7;
49
49
  const HYSTERESIS = 0.15;
50
50
  const FITTS = { a: 0.1, b: 0.15 }; // seconds; only ratios matter here
51
- const SHAPES = ["board", "map", "tasks", "agents", "week", "standup", "todo", "drift", "settings", "reports"];
51
+ const SHAPES = ["board", "map", "tasks", "agents", "week", "standup", "todo", "drift", "settings", "reports", "marketing"];
52
52
 
53
53
  function loadAdapt(file = ADAPT_FILE) {
54
54
  try { const d = JSON.parse(readFileSync(file, "utf8")); return { shapes: d.shapes || {}, trans: d.trans || {}, hours: d.hours || {}, modes: d.modes || {}, slots: d.slots || {}, shown: d.shown || null, events: d.events || 0 }; }
package/agents.mjs CHANGED
@@ -9,7 +9,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync
9
9
  import { randomBytes, createHash } from "node:crypto";
10
10
  import { CONFIG_DIR, loadConfig, saveConfig, loadTasks, TASK_MAX, clipWords, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
11
11
  import { parseRun, lastRunText, readRunLog } from "./work.mjs";
12
- import { parseFacts } from "./handover.mjs";
12
+ import { parseFacts, leftToYou } from "./handover.mjs";
13
13
  import { scanRoots, repoPathMap } from "./scan.mjs";
14
14
  import { knowledgeFolders } from "./knowledge.mjs";
15
15
 
@@ -141,6 +141,17 @@ function connectorsLine(tmpl = handoffCmd(), file, linked) {
141
141
  // For Settings → Handoff: the connectors, whether this command's runs get them, and
142
142
  // the sites linked in Symbiot that aren't Claude connectors (so runs can't use them).
143
143
  function connectorsInfo(file) { const conns = claudeConnectors("", file); return { claude: isClaudeCmd(handoffCmd()), list: conns.map(({ name, ready }) => ({ name, ready })), links: linkedConnectors(conns) }; }
144
+ // Connections, before you link: which sites agent runs can use too. A link signs
145
+ // Symbiot's browser in; a run reaches a site only through Claude's own connector for
146
+ // it (LINK_CONNECTOR), and only with Claude Code as your agent (or, with none picked
147
+ // yet, on this computer to pick). { claude, sites: { id: { name, connector, ready } } }:
148
+ // connector "" while you haven't connected it in claude.ai.
149
+ const claudeAgent = () => { const c = handoffCmd(); return isClaudeCmd(c) || (!c && hasCmd("claude")); };
150
+ function linkReach(conns = claudeConnectors(), claude = claudeAgent()) {
151
+ const sites = {};
152
+ for (const [id, [name, re]] of Object.entries(LINK_CONNECTOR)) { const c = conns.find((x) => re.test(x.name)); sites[id] = { name, connector: c ? c.name : "", ready: !!c && c.ready }; }
153
+ return { claude, sites };
154
+ }
144
155
  const fillHandoff = (tmpl, repoPath, prompt = HANDOFF_PROMPT) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(prompt));
145
156
  // ---- agents work on their own --------------------------------------------------------
146
157
  // Talk to an agent and it just does the work: permission checks skipped, one
@@ -271,14 +282,54 @@ function earlierRuns() {
271
282
  for (const r of loadRuns()) {
272
283
  if (tracked.has(r.path) || !existsSync(join(r.path, ".symbiot"))) continue;
273
284
  const busy = runningHandoff(r.path), ask = agentQuestions(r.path, r.name), wait = waitingFor(r.path), facts = busy ? [] : factsOf(r.path);
274
- if (!busy && !ask.questions.length && !wait && !facts.length) continue;
275
285
  const log = join(r.path, ".symbiot", "agent.log"), startedAt = (busy && busy.startedAt) || Number(r.startedAt) || Date.now();
276
- let tail = "", work = null, progress = null, end = Date.now(); try { ({ tail, work, progress } = workOf(r.path, readRunLog(log))); if (!busy) end = statSync(log).mtimeMs; } catch {}
277
- out.push({ id: "earlier-" + digest(r.path).slice(0, 8), name: String(r.name || r.path.split("/").pop()), path: r.path, status: busy ? "running" : "done", earlier: true, elapsed: Math.max(0, end - startedAt), exitCode: null, tail, work, progress, changed: agentChanges(r.path, startedAt), ask, held: heldTasks(r.path), waiting: wait, remember: facts.length ? facts : null, fromHeld: false });
286
+ let end = Date.now(); if (!busy) try { end = statSync(log).mtimeMs; } catch {}
287
+ const needs = busy ? null : needsOf(r.path, { final: lastFinal(r.path), waiting: wait, end });
288
+ if (!busy && !ask.questions.length && !wait && !facts.length && !needs) continue;
289
+ let tail = "", work = null, progress = null; try { ({ tail, work, progress } = workOf(r.path, readRunLog(log))); } catch {}
290
+ out.push({ id: "earlier-" + digest(r.path).slice(0, 8), name: String(r.name || r.path.split("/").pop()), path: r.path, status: busy ? "running" : "done", earlier: true, startedAt, endedAt: busy ? null : end, elapsed: Math.max(0, end - startedAt), exitCode: null, tail, work, progress, changed: agentChanges(r.path, startedAt), ask, held: heldTasks(r.path), waiting: wait, needs, remember: facts.length ? facts : null, fromHeld: false });
278
291
  }
279
292
  return out;
280
293
  }
281
294
 
295
+ // ---- what a finished run left to you ----------------------------------------
296
+ // A run that ended saying something waits on you (handover.mjs leftToYou: "the
297
+ // draft isn't sent", "needs sudo, and only you can do that"), or waiting on a step
298
+ // an answer of yours picked (waitingFor), needs you, and says so: on its block in
299
+ // Agents and on Home, with what to check first. It did end, so before this nothing
300
+ // showed it: Home said nothing needed you while a reply to Cale sat in Drafts. It
301
+ // stays for NEEDS_FOR, until you go ahead or skip it (settled.json keeps which, per
302
+ // folder), a newer run there says something else, or a newer ops run took up the
303
+ // same task. { kind: "approve"|"step", what, check, label, key, waiting? } or null.
304
+ const NEEDS_FOR = 3 * 86400000, SETTLED = join(CONFIG_DIR, "settled.json"), ACT_DIR = join(CONFIG_DIR, "drafts") + "/";
305
+ const loadSettled = () => { try { const o = JSON.parse(readFileSync(SETTLED, "utf8")); return o && typeof o === "object" && !Array.isArray(o) ? o : {}; } catch { return {}; } };
306
+ function settleNeeds(path, key) {
307
+ const o = loadSettled(); o[path] = String(key || "");
308
+ try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(SETTLED, JSON.stringify(o, null, 2), { mode: 0o600 }); } catch {}
309
+ }
310
+ // A streaming run's last words (its final answer), read once per change of its log.
311
+ const FINALS = new Map();
312
+ function lastFinal(path) {
313
+ const log = join(path, ".symbiot", "agent.log"); let m = 0; try { m = statSync(log).mtimeMs; } catch { return ""; }
314
+ const c = FINALS.get(path); if (c && c.m === m) return c.final;
315
+ let final = ""; try { const w = parseRun(lastRunText(readRunLog(log))); final = w.stream ? w.final : ""; } catch {}
316
+ FINALS.set(path, { m, final }); return final;
317
+ }
318
+ const firstTask = (path) => (readSymbiot(path, "TASKS.md").match(/^\s*-\s*\[[ x]\]\s*(.+)$/im) || [])[1] || "";
319
+ // an ops run (a folder of its own each time) whose task a newer ops run took up
320
+ function takenUp(path, end) {
321
+ if (!path.startsWith(ACT_DIR)) return false;
322
+ const t = firstTask(path); return !!t && loadRuns().some((r) => r.path !== path && r.path.startsWith(ACT_DIR) && Number(r.startedAt) > end && sameTask(firstTask(r.path), t));
323
+ }
324
+ function needsOf(path, { final = "", waiting = null, end = Date.now(), now = Date.now() } = {}) {
325
+ if (now - end > NEEDS_FOR) return null;
326
+ const n = waiting ? { kind: "step", what: clipWords(String(waiting.step || "").replace(/^\s*👤\s*(You\s*(\([^)]*\))?:)?\s*/, "").trim(), 240), check: "", label: "", waiting: true } : leftToYou(final);
327
+ if (!n || !n.what) return null;
328
+ const key = digest(n.what);
329
+ if (loadSettled()[path] === key || takenUp(path, end)) return null;
330
+ return { ...n, key };
331
+ }
332
+
282
333
  // ---- a step that's yours: wait for it -----------------------------------------
283
334
  // An answer that picks a "👤 You:" option leaves a step for the user to do (put a
284
335
  // key in .env, allow a command in Settings). Starting the agent before it's done
@@ -484,6 +535,9 @@ const AGENT_LIST = [
484
535
  ["gemini", "Gemini — make changes", 'gemini --yolo -p "{prompt}"'],
485
536
  ["cursor-agent", "Cursor agent", 'cursor-agent -p "{prompt}"'],
486
537
  ];
538
+ // The agent to offer a new user in one click (Home's "Pick your agent"): the first
539
+ // coding agent on this computer, in AGENT_LIST's order (Claude Code first). null if none.
540
+ function pickAgent() { const a = AGENT_LIST.find(([cmd]) => hasCmd(cmd)); return a ? { label: a[1], name: a[1].split(" — ")[0], tmpl: a[2] } : null; }
487
541
  function detectHandoffs() {
488
542
  const editors = [];
489
543
  for (const [cmd, label, app] of IDE_LIST) {
@@ -885,8 +939,10 @@ function agentsList() {
885
939
  const { tail, work, progress } = workOf(e.path, readRunLog(e.log));
886
940
  const first = !seen.has(e.path); seen.add(e.path);
887
941
  const facts = first && e.status !== "running" ? factsOf(e.path) : [];
888
- return { id: e.id, name: e.name, path: e.path, status: e.status, startedAt: e.startedAt, endedAt: e.endedAt, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, work, progress, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, waiting: first && e.status !== "running" ? waitingFor(e.path) : null, remember: facts.length ? facts : null, fromHeld: !!e.fromHeld };
942
+ const waiting = first && e.status !== "running" ? waitingFor(e.path) : null;
943
+ const needs = first && e.status !== "running" ? needsOf(e.path, { final: work ? tail : "", waiting, end: e.endedAt || Date.now() }) : null;
944
+ return { id: e.id, name: e.name, path: e.path, status: e.status, startedAt: e.startedAt, endedAt: e.endedAt, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, work, progress, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, waiting, needs, remember: facts.length ? facts : null, fromHeld: !!e.fromHeld };
889
945
  }).concat(earlierRuns());
890
946
  }
891
947
 
892
- export { FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, fillHandoff, runHandoff, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList , autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope , withTrust, resumeFor, noteSession, trustFull, GUARD_SETTINGS };
948
+ export { FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, linkReach, fillHandoff, runHandoff, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, pickAgent, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList, needsOf, settleNeeds, NEEDS_FOR , autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope , withTrust, resumeFor, noteSession, trustFull, GUARD_SETTINGS };
package/handover.mjs CHANGED
@@ -24,7 +24,7 @@ function handoverRules(lanes = [], here = "") {
24
24
  }
25
25
  // The rule for options in QUESTIONS.md: the user gets only what no agent can do,
26
26
  // and the option says which kind it is.
27
- 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. A permission your run doesn't have (a command, a folder) is never a chore for the user. Write the rules, as narrow as the task needs, to `.symbiot/allowlist.proposed.json` (`{\"permissions\": {\"allow\": [...], \"deny\": [...], \"additionalDirectories\": [...]}}`), ask for it in QUESTIONS.md with the option `👤 You (only you: a permission): allow the list in .symbiot/allowlist.proposed.json`, and stop. If it stays inside the user's work (the folders they gave Symbiot, not ~/.ssh or ~/.config), asks for nothing wide, and doesn't publish or reach another machine, Symbiot turns it on by itself and runs you again: no question reaches the user. Their knowledge folders are already yours to read. Never ask them to copy files or run claude in a terminal for it. Decide what can be undone yourself, as a trusted colleague would: the approach, names, which of two fixes, how to lay files out; pick, do it, and say what you chose and why in your last message. Ask only about what can't be undone, costs money, goes out in the user's name, or needs who they are, and then give 2–3 options with your pick first, marked (recommended). Their answer comes back into this same conversation, so you'll remember everything when it does. Symbiot's membrane (its guard) stops the few things only the user does (pushing to main, publishing, deleting outside your folder, sudo, their keys); if it stops you, ask, don't work around it.";
27
+ 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. A permission your run doesn't have (a command, a folder) is never a chore for the user. Write the rules, as narrow as the task needs, to `.symbiot/allowlist.proposed.json` (`{\"permissions\": {\"allow\": [...], \"deny\": [...], \"additionalDirectories\": [...]}}`), ask for it in QUESTIONS.md with the option `👤 You (only you: a permission): allow the list in .symbiot/allowlist.proposed.json`, and stop. If it stays inside the user's work (the folders they gave Symbiot, not ~/.ssh or ~/.config), asks for nothing wide, and doesn't publish or reach another machine, Symbiot turns it on by itself and runs you again: no question reaches the user. Their knowledge folders are already yours to read. Never ask them to copy files or run claude in a terminal for it. Decide what can be undone yourself, as a trusted colleague would: the approach, names, which of two fixes, how to lay files out; pick, do it, and say what you chose and why in your last message. Ask only about what can't be undone, costs money, goes out in the user's name, or needs who they are, and then give 2–3 options with your pick first, marked (recommended). Their answer comes back into this same conversation, so you'll remember everything when it does. Symbiot's membrane (its guard) stops the few things only the user does (pushing to main, publishing, deleting outside your folder, sudo, their keys); if it stops you, ask, don't work around it. Whatever waits on the user's OK (sending, posting, deleting, paying, closing) or on a step only they can take goes in QUESTIONS.md as a question, with what they should check first in its context line, never only in your last message: they aren't asked about what your last message says.";
28
28
 
29
29
  // What a run hands back to Symbiot itself (handback.mjs reads it): facts for
30
30
  // memory, and emails that wait on a reply, which Symbiot then watches for; and
@@ -53,6 +53,45 @@ function parseAwaiting(text) {
53
53
  })).filter((x) => x.subject).slice(0, 20);
54
54
  }
55
55
 
56
+ // ---- what a run's last words leave to the user ----------------------------------
57
+ // A run that ends saying something waits on the user ("the draft isn't sent",
58
+ // "only you can do that", "needs sudo") without asking it in QUESTIONS.md left it
59
+ // where nothing showed it: the email to Jono sat in Drafts and nothing asked for
60
+ // the OK. leftToYou finds it: { kind, what, check, label } or null.
61
+ // kind "approve": what an agent does once the user says go (send, post, delete, pay,
62
+ // close); "step": what only the user can do (sudo, a password, signing in). what:
63
+ // the sentence that says so; check: what to check first (the catch it named, else
64
+ // the point's next sentence); label: the point's bold name ("Reply to Cale").
65
+ const HELD = /\b(?:isn['’]t|wasn['’]t|not|never|hasn['’]t been|haven['’]t|didn['’]t) (?:yet )?(?:been )?(?:sent|send it|posted|post it)\b|\bunsent\b|\bbefore you (?:send|post)\b|\bfor you to (?:send|post)\b|\b(?:send|sending|post|posting) it yourself\b|\byou(?:['’]ll| will) (?:send|post) it\b|\bwaits? (?:on|for) your (?:ok|okay|go-ahead|approval|say-so)\b/i;
66
+ const YOURS = /\bonly you (?:can|could|do)\b|\b(?:needs?|waits? (?:on|for)|waiting (?:on|for)) (?:your (?!review)\w+|you to)\b|\btype your password\b|\bneeds sudo\b/i;
67
+ const DID = /\b(?:I|we)(?:['’]ve| have)? (?:sent|posted|deleted|paid|closed)\b|\balready (?:went out|sent|posted)\b|\bnothing (?:was |is )?(?:sent|posted)\b/i;
68
+ const ACTS = /\b(?:send|sent|sending|post|posted|publish|delete|remove|pay|close|cancel)\b/i;
69
+ const CATCH = /\b(?:catch|before you|check|make sure|careful|fix (?:that|it)|first)\b/i;
70
+ const NOT_THEIRS = /\bconnectors?\b|\bclaude\.ai\b/i; // a connector to sign in again shows on Home already
71
+ const SENDS = /draft|reply|email|mail|message|post/i;
72
+ function leftToYou(text) {
73
+ const points = String(text || "").replace(/```[\s\S]*?```/g, (m) => m.replace(/\s+/g, " ")).split(/\n+|\s+-\s+(?=\*\*)/).map((p) => p.replace(/^\s*[-*•]\s+/, "").trim()).filter(Boolean)
74
+ .map((p) => { const lm = p.match(/^\*\*([^*]{2,60}?):?\*\*:?\s*/), body = lm ? p.slice(lm[0].length) : p;
75
+ return { label: lm ? lm[1].trim() : "", ss: body.split(/(?<=[.!?:])\s+(?=[A-Z`"*(])/).map((s) => s.trim()).filter(Boolean), off: NOT_THEIRS.test(p) }; });
76
+ const clean = (x, n) => { x = String(x || "").replace(/\*\*/g, "").replace(/\s+/g, " ").trim(); return x.length > n ? x.slice(0, n - 1).replace(/\s+\S*$/, "") + "…" : x; };
77
+ for (let k = 0; k < points.length; k++) {
78
+ const { label, ss, off } = points[k]; if (off) continue;
79
+ const i = ss.findIndex((s) => !DID.test(s) && (HELD.test(s) || YOURS.test(s)));
80
+ if (i < 0) continue;
81
+ const s = ss[i], next = points[k + 1] && !points[k + 1].off ? points[k + 1].ss : [];
82
+ // what to check first: the catch it named (in this point, then the next), else what it says next
83
+ const catches = ss.filter((x, j) => j !== i && CATCH.test(x) && !DID.test(x));
84
+ const elsewhere = points.filter((p, j) => j !== k && !p.off).flatMap((p) => p.ss.filter((x) => /\byourself\b|\bbefore you\b|\bcatch\b/i.test(x) && !DID.test(x)).map((x) => (p.label ? `${p.label}: ${x}` : x)));
85
+ let check = (catches.length ? catches.slice(0, 2).join(" ") : elsewhere[0]) || ss[i + 1] || next[0] || "";
86
+ if (/:$/.test(check)) { const seq = [...ss.slice(i + 1), ...next, ...((points[k + 2] && points[k + 2].ss) || [])], at = seq.indexOf(check); if (at >= 0 && seq[at + 1]) check += " " + seq[at + 1]; } // "paste this:" and then the command
87
+ // "The draft isn't sent." says which draft only in its own point ("**Draft:** it's in Gmail, to …")
88
+ const about = !label && SENDS.test(s) ? points.find((p) => p.label && SENDS.test(p.label) && p.ss.length) : null;
89
+ const what = label ? `${label}: ${s}` : about ? `${s} ${about.label}: ${about.ss[0]}` : s;
90
+ return { kind: HELD.test(s) || ACTS.test(s) ? "approve" : "step", what: clean(what, 240), check: clean(check === s ? "" : check, 240), label: clean(label || (about && about.label), 60) };
91
+ }
92
+ return null;
93
+ }
94
+
56
95
  // What an agent handed over: [{ lane, text }] from its .symbiot/HANDOFF.md.
57
96
  function parseHandoffs(md) {
58
97
  const out = []; let cur = null;
@@ -65,4 +104,4 @@ function parseHandoffs(md) {
65
104
  return out.map((h) => ({ lane: h.lane, text: h.text.trim().slice(0, HANDOVER_MAX) })).filter((h) => h.lane && h.text);
66
105
  }
67
106
 
68
- export { OPS, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK, parseFacts, parseAwaiting, parseHandoffs };
107
+ export { OPS, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK, parseFacts, parseAwaiting, parseHandoffs, leftToYou };
package/home.mjs CHANGED
@@ -14,7 +14,7 @@ import { homedir } from "node:os";
14
14
  import { readFileSync, writeFileSync } from "node:fs";
15
15
  import { watchBoard } from "./watch.mjs";
16
16
  import { pendingReview, pushTasks } from "./tasks.mjs";
17
- import { agentsList, runHandoff, runningHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing } from "./agents.mjs";
17
+ import { agentsList, runHandoff, runningHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent } from "./agents.mjs";
18
18
  import { linksState } from "./links.mjs";
19
19
  import { knowledgeState } from "./knowledge.mjs";
20
20
  import { CONFIG_DIR, loadTasks } from "./core.mjs";
@@ -25,6 +25,7 @@ import { resolveProvider } from "./ai.mjs";
25
25
  import { reportsNews } from "./reports.mjs";
26
26
  import { awaitingState, inboxSight } from "./handback.mjs";
27
27
  import { newClashes } from "./checks.mjs";
28
+ import { postsState } from "./post.mjs";
28
29
 
29
30
  const KEEP = 15000; // the slower reads (git per repo awaiting review) are cached this long
30
31
  let cached = null;
@@ -54,9 +55,11 @@ const laneOfPath = (path, map) => Object.keys(map || {}).find((n) => map[n] ===
54
55
  const FAILED_FOR = 86400000, dropped = new Set();
55
56
  const firstLine = (t) => String(t || "").split("\n").find((l) => l.trim()) || "";
56
57
  // What stopped and can't go on without you, as questions: a handover that couldn't
57
- // start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"), and a run
58
- // that ended in an error. Each is answered on its blob (homeAnswer), on Home and on
59
- // its lane in Tasks. repo: its lane ("" for an ops run).
58
+ // start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"), a run
59
+ // that ended waiting on you (agents.mjs needsOf: a draft for your OK, a sudo step,
60
+ // a step your answer left you) with what to check first, and a run that ended in an
61
+ // error. Each is answered on its blob (homeAnswer), on Home and on its lane in
62
+ // Tasks. repo: its lane ("" for an ops run).
60
63
  function troubles({ stuck = [], list = [], map = {}, named = displayName, now = Date.now() } = {}) {
61
64
  const out = [], seen = new Set();
62
65
  for (const t of stuck) {
@@ -66,9 +69,16 @@ function troubles({ stuck = [], list = [], map = {}, named = displayName, now =
66
69
  }
67
70
  for (const a of list) {
68
71
  if (seen.has(a.path)) continue; seen.add(a.path); // the newest run in each folder (agentsList: newest first)
72
+ if (a.status === "running" || (a.ask && a.ask.questions && a.ask.questions.length)) continue; // at work, or it asked: that's its question
73
+ const lane = laneOfPath(a.path, map), repo = lane || "", nm = repo ? named(map[lane], repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, "").replace(/^\W*What['’]s needed:\W*/i, "").replace(/^./, (c) => c.toUpperCase()), 60);
74
+ const n = a.needs;
75
+ if (n && !dropped.has("needs:" + a.id)) {
76
+ const ok = n.kind === "approve";
77
+ out.push({ kind: "ask", fix: "needs", id: "needs:" + a.id, path: a.path, repo, name: nm, title: ok ? `${nm} waits for your OK` : `${nm} needs you`, sub: plain(n.label || n.what, 90),
78
+ q: n.what + (n.check ? ` Check first: ${n.check}` : ""), options: ok ? ["Go ahead (recommended)", "Skip"] : ["Done it (recommended)", "Skip"], shape: "agents" });
79
+ continue;
80
+ }
69
81
  if (a.status !== "failed" || dropped.has(a.id) || (a.endedAt && now - a.endedAt > FAILED_FOR)) continue;
70
- if (a.ask && a.ask.questions && a.ask.questions.length) continue; // it asked: that's its question
71
- const lane = laneOfPath(a.path, map), repo = lane || "", nm = repo ? named(map[lane], repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, ""), 60);
72
82
  const said = plain(String(a.tail || "").split(/\n+/).filter((l) => l.trim()).pop() || "", 140);
73
83
  out.push({ kind: "ask", fix: "failed", id: "failed:" + a.id, path: a.path, repo, name: nm, title: `${nm}'s run stopped`, sub: said || `it exited with code ${a.exitCode}`,
74
84
  q: `Its run stopped with an error${said ? `: ${said}` : ` (exit code ${a.exitCode})`}. Run it again?`, options: ["Run it again (recommended)", "Skip"], shape: "agents" });
@@ -96,6 +106,10 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
96
106
  const agentGone = deps.agentGone || (() => { try { return handoffCmd() ? agentMissing() : ""; } catch { return ""; } });
97
107
  const signedOut = deps.signedOut || (() => { try { const c = connectorsInfo(); return c.claude ? (c.links || []).filter((x) => x.connector && !x.ready) : []; } catch { return []; } });
98
108
  const stuck = deps.stuck || (() => { try { return stuckHandovers({ now }); } catch { return []; } });
109
+ const agentCmd = deps.agentCmd || (() => { try { return handoffCmd(); } catch { return "x"; } });
110
+ const offer = deps.pickAgent || (() => { try { return pickAgent(); } catch { return null; } });
111
+ // Marketing (its own section, off the Dashboard) once posts can be drafted, or there are some
112
+ const marketing = deps.marketing || (() => { try { const p = postsState(); return !!(p.canDraft || p.posts.length || p.done.length); } catch { return false; } });
99
113
  const named = deps.name || displayName;
100
114
  const you = [], map = repos() || {};
101
115
  // What Symbiot works through (an AI, your agent, the connectors runs use) shows
@@ -105,6 +119,9 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
105
119
  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" });
106
120
  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" });
107
121
  if (!Object.keys(map).length) you.push({ kind: "setup", id: "setup:folders", title: "Show me your work", sub: "where your repos are", shape: "settings", focus: "work" });
122
+ // no agent yet: Send to repos and Go would only write TASKS.md files nothing runs, so
123
+ // Home didn't say "All handled" truthfully. One click for the one on this computer.
124
+ 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 } } : {}) }); }
108
125
  for (const r of pending()) {
109
126
  if (!r.path || r.running) continue;
110
127
  const n = (r.tasks || []).length, files = (r.files || []).length;
@@ -140,17 +157,23 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
140
157
  if (rep.count) feeds.push({ id: "feed:reports", title: "Reports", sub: `${rep.count} unread`, latest: rep.latest, count: rep.count, shape: "reports" });
141
158
  const hs = ((lanes() || {}).handoffs || []).slice(0, 5).map((h) => ({ from: h.from, to: h.to, text: h.text, status: h.status, ...(h.times > 1 ? { times: h.times } : {}) }));
142
159
  const working = list.filter((a) => a.status === "running").length;
143
- const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, at: now };
160
+ const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, marketing: marketing(), at: now };
144
161
  if (!deps.board) cached = out;
145
162
  return out;
146
163
  }
147
164
 
148
- // An answer on a stuck handover's or a failed run's blob. pick: the option's place
149
- // (0 goes ahead, 1 skips); text: your own words, which go ahead with them unless
150
- // they say no. The handover's run (lanes.mjs allowHandover) reports back to the
151
- // agent that asked; a failed run starts again in its folder, with your words in its
152
- // ANSWERS.md. { ok, said } or { error }.
165
+ // An answer on a stuck handover's, a waiting run's or a failed run's blob. pick: the
166
+ // option's place (0 goes ahead, 1 skips); text: your own words, which go ahead with
167
+ // them unless they say no. The handover's run (lanes.mjs allowHandover) reports back
168
+ // to the agent that asked; a run waiting on your OK or your step starts again with
169
+ // "go ahead" or "done" in its ANSWERS.md (skipped, it doesn't ask again); a failed
170
+ // run starts again in its folder, with your words in its ANSWERS.md. { ok, said } or { error }.
153
171
  const SAYS_NO = /^\s*(skip|no|nope|don'?t|do not|cancel|leave it|never ?mind|stop)\b/i;
172
+ // An answer, in the folder's ANSWERS.md, for the run that picks it up.
173
+ function noteAnswer(path, heading, text) {
174
+ const f = join(path, ".symbiot", "ANSWERS.md"); let had = ""; try { had = readFileSync(f, "utf8"); } catch {}
175
+ try { writeFileSync(f, `${(had || "# Answers\n").replace(/\s*$/, "")}\n\n### ${String(heading).replace(/\s+/g, " ").trim()}\n${text}\n_answered ${new Date().toISOString().slice(0, 10)}_\n`); } catch {}
176
+ }
154
177
  function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
155
178
  id = String(id || ""); text = String(text || "").trim().slice(0, 2000);
156
179
  const go = text ? !SAYS_NO.test(text) : Number(pick) === 0, ref = id.replace(/^\w+:/, "");
@@ -164,11 +187,22 @@ function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
164
187
  const a = (deps.agents || agentsList)().find((x) => x.id === ref); if (!a) return { error: "That run isn't here any more." };
165
188
  if (!go) { dropped.add(ref); return { ok: true, said: "Skipped." }; }
166
189
  if ((deps.running || runningHandoff)(a.path)) return { ok: true, said: "An agent is already at work there." };
167
- if (text) { const f = join(a.path, ".symbiot", "ANSWERS.md"); let had = ""; try { had = readFileSync(f, "utf8"); } catch {}
168
- try { writeFileSync(f, `${(had || "# Answers\n").replace(/\s*$/, "")}\n\n### Your last run stopped with an error\n${text}\n_answered ${new Date().toISOString().slice(0, 10)}_\n`); } catch {} }
190
+ if (text) noteAnswer(a.path, "Your last run stopped with an error", text);
169
191
  const e = (deps.run || runHandoff)(a.path, { force: true }); dropped.add(ref);
170
192
  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." };
171
193
  }
194
+ if (id.startsWith("needs:")) {
195
+ const a = (deps.agents || agentsList)().find((x) => x.id === ref), n = a && a.needs;
196
+ if (!n) return { error: "That isn't waiting on you any more." };
197
+ const settle = deps.settle || settleNeeds;
198
+ if (!go) { settle(a.path, n.key); dropped.add(id); return { ok: true, said: "Skipped. It won't ask again." }; }
199
+ if ((deps.running || runningHandoff)(a.path)) return { ok: true, said: "An agent is already at work there." };
200
+ const said = n.kind === "approve" ? `Go ahead: ${text || "do it as you had it, once you've checked what you flagged"}.` : `Done: the user did it${text ? ` (${text})` : ""}. Check it worked, then carry on.`;
201
+ noteAnswer(a.path, n.what, said);
202
+ const e = (deps.run || runHandoff)(a.path, { force: true });
203
+ if (e && e.id && !e.blocked) { settle(a.path, n.key); dropped.add(id); }
204
+ return e && e.id && !e.blocked ? { ok: true, said: n.kind === "approve" ? "Its agent is going ahead." : "Its agent is checking and carrying on.", rerun: e.id } : { error: (e && e.note) || "It didn't start. Check your agent in Settings." };
205
+ }
172
206
  return { error: "Answer that one on its block in Agents." };
173
207
  }
174
208
 
package/index.mjs CHANGED
@@ -636,7 +636,7 @@ async function cmdPost() {
636
636
  if (r.retried) console.log("\n" + c.d("The first try kept none (each claimed what git doesn't show), so Symbiot tried again by itself."));
637
637
  if (r.pictures) console.log(c.d(`${r.pictures === 1 ? "One draft names" : `${r.pictures} drafts name`} a screen of an app you run here, so ${r.pictures === 1 ? "it has its picture" : "they have their pictures"} already: keep or remove (symbiot post remove <id> <media id>).`));
638
638
  if (r.dropped.length) console.log("\n" + c.y(`Dropped ${r.dropped.length}: `) + r.dropped.map((x) => `${POST_KIND[x.kind] || x.kind} (${x.cited ? "claimed " + x.unsupported.join(", ") + ", which git doesn't show" : "cited nothing from git"})`).join("; "));
639
- console.log("\n" + c.d(`${r.posts.length} draft${r.posts.length === 1 ? "" : "s"} from ${r.facts} things git shows this week, in the voice of your ${r.voice} example${r.voice === 1 ? "" : "s"}. They wait on the app's Dashboard too.`));
639
+ console.log("\n" + c.d(`${r.posts.length} draft${r.posts.length === 1 ? "" : "s"} from ${r.facts} things git shows this week, in the voice of your ${r.voice} example${r.voice === 1 ? "" : "s"}. They wait under Marketing in the app too.`));
640
640
  console.log(c.d("Nothing is posted until you approve one: symbiot post approve <id> (or edit / skip)."));
641
641
  return;
642
642
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.54.3",
4
- "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
3
+ "version": "0.54.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": {
7
7
  "symbiot": "index.mjs"
package/post.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // symbiot — Post: your week's real work as three draft posts (LinkedIn), in your
2
- // own voice, each waiting on you on the Dashboard: Approve, Edit or Skip.
2
+ // own voice, each waiting on you under Marketing: Approve, Edit or Skip.
3
3
  //
4
4
  // - What it writes from: the last 7 days of commits, the release tags (and gh's
5
5
  // releases, when gh works) and the CHANGELOG.md sections dated in that window,
@@ -21,7 +21,7 @@
21
21
  //
22
22
  // Stored in ~/.config/symbiot/posts.json, readable by you only:
23
23
  // { posts: [{ id, kind, text, show, facts: [n], sources: [fact line], media: [{ id, kind, file, name, from, url?, bytes, added }], platform, status, drafted, edited?, approved?, skipped? }], lastUrl }
24
- // status: waiting (on the Dashboard), approved, skipped or replaced (a newer draft took its place).
24
+ // status: waiting (under Marketing), approved, skipped or replaced (a newer draft took its place).
25
25
  import { join, dirname } from "node:path";
26
26
  import { readFileSync, writeFileSync, appendFileSync, mkdirSync, chmodSync, existsSync, readdirSync, rmSync, readlinkSync } from "node:fs";
27
27
  import { spawn, spawnSync } from "node:child_process";
@@ -32,7 +32,7 @@ import { resolveProvider, write } from "./ai.mjs";
32
32
  import { commits, discoveredRepos } from "./scan.mjs";
33
33
  import { readTexts, pagePicture, pageClip, siteUrl, CLIP } from "./headless.mjs";
34
34
 
35
- const PATHS = { posts: join(CONFIG_DIR, "posts.json"), log: join(CONFIG_DIR, "posts-log.jsonl"), voice: join(CONFIG_DIR, "voice.md"), media: join(CONFIG_DIR, "post-media") };
35
+ const PATHS = { posts: join(CONFIG_DIR, "posts.json"), log: join(CONFIG_DIR, "posts-log.jsonl"), voice: join(CONFIG_DIR, "voice.md"), media: join(CONFIG_DIR, "post-media"), test: join(CONFIG_DIR, "post-test.json") };
36
36
  const PLATFORM = "linkedin";
37
37
  // LinkedIn's share box, opened for you to paste an approved post into.
38
38
  const SHARE_URL = "https://www.linkedin.com/feed/?shareActive=true";
@@ -42,7 +42,7 @@ const MAX_FACTS = 120, MAX_TEXT = 3000, KEEP = 200; // LinkedIn takes 3000 chara
42
42
  const q = (s) => JSON.stringify(String(s));
43
43
 
44
44
  const NO_AI = "Symbiot needs an AI to draft your posts, and none is connected. Connect one with symbiot login (or Settings in the app), then run symbiot post again. Nothing was drafted, saved or logged.";
45
- const noVoice = (file) => `Symbiot drafts posts in your voice, from examples of your own posts, and has none yet. Either link LinkedIn (Dashboard → Links → LinkedIn, sign in once) and click Fill from LinkedIn (or run symbiot post voice --linkedin), or paste 5–10 posts you wrote into ${file}, with a line of --- between each. Nothing was drafted.`;
45
+ const noVoice = (file) => `Symbiot drafts posts in your voice, from examples of your own posts, and has none yet. Either link LinkedIn (Marketing → Link LinkedIn, sign in once) and click Fill from LinkedIn (or run symbiot post voice --linkedin), or paste 5–10 posts you wrote into ${file}, with a line of --- between each. Nothing was drafted.`;
46
46
 
47
47
  // ---- your voice ------------------------------------------------------------------
48
48
  // voice.md's examples: what's between lines of ---, each long enough to be a post.
@@ -62,7 +62,7 @@ const tidyPost = (t) => noTagLabels(t).replace(/\s*…\s*(see )?more\s*$/i, "").
62
62
  async function voiceFromLinkedIn({ read = readTexts, paths = PATHS, max = 10 } = {}) {
63
63
  const r = (await read(LINKEDIN_ACTIVITY, LINKEDIN_POST_TEXT)) || { error: "Nothing came back from LinkedIn." };
64
64
  if (r.busy) return { error: "Symbiot's browser is busy (a sign-in window, or a map). Close it, then try again." };
65
- if (r.login || /linkedin\.com\/(login|authwall|uas\/|checkpoint|signup)/i.test(r.url || "")) return { error: "You're not signed in to LinkedIn in Symbiot's browser. Link LinkedIn on the Dashboard (Links → LinkedIn), sign in in the window that opens, close it, then try again." };
65
+ if (r.login || /linkedin\.com\/(login|authwall|uas\/|checkpoint|signup)/i.test(r.url || "")) return { error: "You're not signed in to LinkedIn in Symbiot's browser. Link LinkedIn (Marketing → Link LinkedIn, or Connections), sign in in the window that opens, close it, then try again." };
66
66
  if (r.error) return { error: r.error };
67
67
  const found = []; for (const t of (r.texts || []).map(tidyPost)) if (t.length >= 40 && !found.includes(t)) found.push(t);
68
68
  if (!found.length) return { error: `Found no posts of yours on LinkedIn's activity page. Paste 5–10 of your posts into ${paths.voice} instead, with a line of --- between each.` };
@@ -200,7 +200,7 @@ const SYSTEM = `You draft LinkedIn posts for a developer, from their real work t
200
200
  `Answer with JSON only: {"posts":[{"kind":"shipped","text":"…","show":"…","facts":[1,4]},{"kind":"learned","text":"…","show":"…","facts":[…]},{"kind":"long","text":"…","show":"…","facts":[…]}]}, ` +
201
201
  `where "facts" lists the numbers of every fact the post uses, and "show" is one short line (not posted) saying what picture or short video would show it best, ` +
202
202
  `for them to take: the screen, page or command the post is about, as the facts name it (e.g. "a picture of the new Reports view", "a clip of the install running").`;
203
- // Draft this week's 3 posts and put them on the Dashboard (waiting on you). With
203
+ // Draft this week's 3 posts and put them under Marketing (waiting on you). With
204
204
  // no AI connected it says so and does nothing else: nothing is read, written or
205
205
  // logged. `ask` is ai.mjs write (the tests pass their own); `repos`, `gh`, `now`
206
206
  // and `paths` likewise. Gives { ok, posts, dropped, facts } or { error, code }.
@@ -265,9 +265,10 @@ function logAction(action, p, now = Date.now(), paths = PATHS, extra = {}) {
265
265
  } catch { return false; }
266
266
  }
267
267
  function postLog(paths = PATHS) { try { return readFileSync(paths.log, "utf8").split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } }
268
- // What the Dashboard shows: the drafts waiting on you, the last few you dealt with,
268
+ // What Marketing shows: the drafts waiting on you, the last few you dealt with,
269
269
  // and whether it can draft: an AI connected, and your voice (examples in voice.md,
270
- // or LinkedIn linked to fill it from). Until then the Dashboard says so in a line.
270
+ // or LinkedIn linked to fill it from). Until then Marketing says so in a line, and
271
+ // Home shows no Marketing orb (home.mjs).
271
272
  function postsState(paths = PATHS, { linked = () => (loadConfig().linked || {}) } = {}) {
272
273
  const d = loadPosts(paths), voice = loadVoice(paths.voice).length, connected = !!resolveProvider();
273
274
  let linkedin = false; try { linkedin = !!linked().linkedin; } catch {}
@@ -520,14 +521,71 @@ function openUrl(url) {
520
521
 
521
522
  // ---- replies: who might be a customer ------------------------------------------------
522
523
  // A comment or mention that asks how to install it, what it costs or about its
523
- // licence, or about using it in a team: "maybe a customer" on the Dashboard.
524
+ // licence, or about using it in a team: "maybe a customer" under Marketing and on the Dashboard.
524
525
  // Gives why ("install", "pricing", "team") or "".
525
526
  const CUSTOMER = [
526
527
  ["pricing", /\b(?:pric(?:e|es|ed|ing)|costs?|how much|licen[cs](?:e|es|ing)|subscriptions?|paid (?:plan|version|tier)|free tier|pay(?:ing)? for|per (?:seat|user|month))\b/i],
527
528
  ["install", /\b(?:install(?:ing|ed|ation)?|npm i|npx|download(?:ing)?)\b|\bhow (?:do|can|would|could) (?:i|we|you|one) (?:set (?:it |this )?up|get started|start|use|try|get|run)\b|\bwhere (?:can|do) (?:i|we) (?:get|find)\b/i],
528
529
  ["team", /\b(?:my|our|whole)\s+(?:dev\s+|engineering\s+)?(?:team|teams|company|org|organi[sz]ation|startup)\b|\bteam (?:use|plan|licen[cs]e|version|pricing)\b|\bfor (?:a |small )?teams\b|\b(?:enterprise|seats)\b/i],
529
530
  ];
531
+ // ---- the 4-week test: a row a week (Marketing) -------------------------------------
532
+ // Does posting bring anyone? Four weeks, a row each. Weeks run from the day of your
533
+ // first draft; week 1 is the first of them a post went out in (until one has, the one
534
+ // you're in: a week with nothing posted doesn't count), or the day post-test.json's
535
+ // start names, when you've moved it ({ "start": "2026-10-13" }). Each row: posts published, and how many
536
+ // carried a picture or video; replies on LinkedIn (its watched notifications, marked
537
+ // by watch.mjs markNews), the ones that may be a customer and those asking what it
538
+ // costs; and npm installs of the packages your posts are about. Profile visits are
539
+ // only in LinkedIn's own analytics. null before there's a first draft.
540
+ const TEST_WEEKS = 4;
541
+ const dayStart = (ts) => { const d = new Date(ts); d.setHours(0, 0, 0, 0); return d.getTime(); };
542
+ const addDays = (ts, n) => { const d = new Date(ts); d.setDate(d.getDate() + n); return d.getTime(); };
543
+ const ymd = (ts) => { const d = new Date(ts); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; };
544
+ function testPlan(paths = PATHS, now = Date.now()) {
545
+ const posts = loadPosts(paths).posts;
546
+ let set = NaN; try { set = Date.parse(String(JSON.parse(readFileSync(paths.test, "utf8")).start || "").slice(0, 10) + "T00:00:00"); } catch {}
547
+ const first = posts.reduce((m, p) => Math.min(m, Number(p.drafted) || Infinity), Infinity);
548
+ if (!set && first === Infinity) return null;
549
+ let start = set ? dayStart(set) : dayStart(first);
550
+ if (!set) { const out = postLog(paths).filter((l) => l.action === "approved").map((l) => Number(l.ts)).sort((a, b) => a - b)[0], to = out || now; while (addDays(start, 7) <= to) start = addDays(start, 7); }
551
+ return { start, weeks: Array.from({ length: TEST_WEEKS }, (_, i) => [addDays(start, 7 * i), addDays(start, 7 * (i + 1))]), posts };
552
+ }
553
+ // The npm packages your posts are about: the repos their git facts came from
554
+ // ("[21] symbiot · 2026-10-06 · changelog: …"), each one's package.json name unless private.
555
+ function testPackages(posts, map = {}) {
556
+ const repos = new Set(posts.flatMap((p) => (Array.isArray(p.sources) ? p.sources : []).map((x) => (String(x).match(/^(?:\[\d+\]\s*)?(.+?)\s+·\s/) || [])[1]).filter(Boolean)));
557
+ const out = [];
558
+ for (const r of repos) { if (!map[r]) continue; try { const j = JSON.parse(readFileSync(join(map[r], "package.json"), "utf8")); if (j.name && !j.private && !out.includes(j.name)) out.push(j.name); } catch {} }
559
+ return out.slice(0, 3);
560
+ }
561
+ function testWeeks({ paths = PATHS, now = Date.now(), news = [], installs = null, map = {} } = {}) {
562
+ const plan = testPlan(paths, now); if (!plan) return null;
563
+ const log = postLog(paths);
564
+ const rows = plan.weeks.map(([from, to], i) => {
565
+ const inW = (t) => Number(t) >= from && Number(t) < to, pub = log.filter((l) => l.action === "approved" && inW(l.ts)), rep = news.filter((n) => n.social && inW(n.ts));
566
+ return { week: i + 1, from: ymd(from), to: ymd(addDays(to, -1)), started: now >= from, over: now >= to, published: pub.length, media: pub.filter((l) => (l.media || []).length).length,
567
+ replies: rep.length, customers: rep.filter((n) => n.customer).length, pricing: rep.filter((n) => n.customer === "pricing").length, installs: installs && installs[i] != null ? installs[i] : null };
568
+ });
569
+ return { start: ymd(plan.start), end: ymd(addDays(plan.weeks[TEST_WEEKS - 1][1], -1)), rows, packages: testPackages(plan.posts, map), week: rows.filter((r) => r.started).length };
570
+ }
571
+ // npm's daily downloads for those packages, summed per test week (weeks not begun:
572
+ // null): [n, …] or null. get(url) reads npm's JSON (the app passes it: nothing here
573
+ // goes on the network by itself). One read per package for the whole test, kept a few hours.
574
+ const NPM_DAYS = "https://api.npmjs.org/downloads/range/";
575
+ let npmCache = { key: "", at: 0, counts: null };
576
+ async function testInstalls(t, { now = Date.now(), get = null } = {}) {
577
+ if (!t || !t.packages.length || !t.rows[0].started || typeof get !== "function") return null;
578
+ const key = t.packages.join(",") + "|" + t.start; if (npmCache.key === key && now - npmCache.at < 3 * 3600000) return npmCache.counts;
579
+ const counts = t.rows.map((r) => (r.started ? 0 : null));
580
+ for (const p of t.packages) {
581
+ let d = null; try { d = await get(`${NPM_DAYS}${t.start}:${t.end}/${encodeURIComponent(p)}`); } catch {}
582
+ if (!d || !Array.isArray(d.downloads)) return null;
583
+ for (const x of d.downloads) { const r = t.rows.findIndex((w) => x.day >= w.from && x.day <= w.to); if (r >= 0 && counts[r] != null) counts[r] += Number(x.downloads) || 0; }
584
+ }
585
+ npmCache = { key, at: now, counts };
586
+ return counts;
587
+ }
530
588
  function maybeCustomer(text) { const s = String(text || ""); const hit = CUSTOMER.find(([, re]) => re.test(s)); return hit ? hit[0] : ""; }
531
589
 
532
- export { PATHS, PLATFORM, SHARE_URL, KINDS, KIND_LABEL, LINKEDIN_ACTIVITY, NO_AI, voiceOf, loadVoice, voiceFromLinkedIn, tagsIn, changelogIn, gatherFacts, factLine, numbersIn, namesIn, checkClaims, parseDrafts, vetDraft, draftPosts, loadPosts, postLog, postsState, editPost, skipPost, approvePost, copyText, openUrl, maybeCustomer,
590
+ export { TEST_WEEKS, testWeeks, testInstalls, testPackages, PATHS, PLATFORM, SHARE_URL, KINDS, KIND_LABEL, LINKEDIN_ACTIVITY, NO_AI, voiceOf, loadVoice, voiceFromLinkedIn, tagsIn, changelogIn, gatherFacts, factLine, numbersIn, namesIn, checkClaims, parseDrafts, vetDraft, draftPosts, loadPosts, postLog, postsState, editPost, skipPost, approvePost, copyText, openUrl, maybeCustomer,
533
591
  mediaType, mediaDir, mediaWords, addMedia, removeMedia, mediaFile, pictureOfPage, clipOfPage, concatList, toVideo, canClip, NO_FFMPEG, localApps, screenFor, picturesFor, listening };
package/server.mjs CHANGED
@@ -13,7 +13,7 @@ import { createServer } from "node:http";
13
13
  import { randomBytes } from "node:crypto";
14
14
  import { EMBEDDED_UI } from "./ui.mjs";
15
15
  import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary, CONFIG_DIR } from "./core.mjs";
16
- import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull } from "./agents.mjs";
16
+ import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, linkReach, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull } from "./agents.mjs";
17
17
  import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
18
18
  import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
19
19
  import { computeDrift } from "./drift.mjs";
@@ -22,9 +22,9 @@ import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailSt
22
22
  import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
23
23
  import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
24
24
  import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart } from "./desktop.mjs";
25
- import { watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
25
+ import { markNews, newsSince, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
26
26
  import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
27
- import { postsState, draftPosts, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia, removeMedia, mediaFile, mediaDir, pictureOfPage, clipOfPage, openUrl } from "./post.mjs";
27
+ import { testWeeks, testInstalls, postsState, draftPosts, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia, removeMedia, mediaFile, mediaDir, pictureOfPage, clipOfPage, openUrl } from "./post.mjs";
28
28
  import { mindState, forget } from "./mind.mjs";
29
29
  import { lanesTick, lanesState, partlyDone, orcaRelink } from "./lanes.mjs";
30
30
  import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
@@ -332,13 +332,21 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
332
332
  if (u.pathname === "/api/mind") return json(res, mindState());
333
333
  if (u.pathname === "/api/mind/forget" && req.method === "POST") { const b = await readBody(req); return json(res, forget(String(b.id || ""))); }
334
334
  // Links (links.mjs): one click per standard work site: sign in, trust it, watch it.
335
- if (u.pathname === "/api/links") return json(res, linksState());
335
+ if (u.pathname === "/api/links") { let reach = null; try { reach = linkReach(); } catch {} return json(res, { ...linksState(), ...(reach ? { reach } : {}) }); } // reach: which sites agent runs can use too (agents.mjs)
336
336
  if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""))); }
337
337
  if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
338
338
  if (u.pathname === "/api/links/unlink" && req.method === "POST") { const b = await readBody(req); return json(res, unlinkSite(String(b.id || ""))); }
339
339
  // Posts (post.mjs): the week's drafts, waiting on you. Approve only records your yes:
340
340
  // the page copies the post itself and opens LinkedIn's share box; nothing is posted.
341
341
  if (u.pathname === "/api/posts") return json(res, postsState());
342
+ // Marketing: replies on LinkedIn (its watched notifications, marked "maybe a customer"),
343
+ // and the 4-week test's table, with npm installs of the packages the posts are about
344
+ if (u.pathname === "/api/marketing") {
345
+ const ws = watchState(), news = markNews(newsSince(24 * 60), ws.watches), p = postsState(), t = testWeeks({ news, map: repoPathMap() });
346
+ let n = null; try { n = await testInstalls(t, { get: (url) => fetch(url, { signal: AbortSignal.timeout(8000) }).then((r) => (r.ok ? r.json() : null)) }); } catch {}
347
+ if (t && n) t.rows.forEach((r, i) => { r.installs = n[i]; });
348
+ return json(res, { linkedin: p.linkedin, replies: news.filter((x) => x.social && x.ts >= Date.now() - 30 * 86400000).slice(0, 30), test: t });
349
+ }
342
350
  if (u.pathname === "/api/posts/draft" && req.method === "POST") return json(res, await draftPosts());
343
351
  if (u.pathname === "/api/posts/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approvePost(String(b.id || ""), { copy: null })); }
344
352
  if (u.pathname === "/api/posts/edit" && req.method === "POST") { const b = await readBody(req); return json(res, editPost(String(b.id || ""), b.text)); }
package/ui.mjs CHANGED
@@ -101,6 +101,9 @@ body.lq-liquid.lq-light.lq-pooled #lqsinkrow{background:rgba(246,248,251,.94);bo
101
101
  #lqtitle{margin:0;font:650 24px/1.15 var(--sans);letter-spacing:-.02em;color:var(--bone);overflow-wrap:anywhere}
102
102
  #lqtitle .lqpk{margin-left:10px;font:600 11.5px var(--sans);letter-spacing:.06em;text-transform:uppercase;color:var(--amber);vertical-align:middle}
103
103
  #lqpark{align-self:center;padding:5px 12px;font-size:12.5px}
104
+ /* what the open panel does to itself (its Refresh) sits beside Close */
105
+ .lqacts,#lqtools{display:flex;align-items:center;gap:8px;flex:0 0 auto}#lqtools:empty,#lqtools:not(:has(>:not(.hidden))){display:none}
106
+ #pushtasks{padding:7px 14px;font-size:13px}
104
107
  #lqscene{display:none}
105
108
  body.lq-work:not(.lq-pooled) #lqscene{display:block;position:absolute;left:50%;top:50px;transform:translateX(-50%);z-index:3;margin:0;font:650 24px/1.15 var(--sans);letter-spacing:-.02em;color:#F3F0EA;text-shadow:0 1px 12px rgba(0,0,0,.6);pointer-events:none;white-space:nowrap}
106
109
  body.lq-light.lq-work #lqscene{color:#151A21;text-shadow:0 1px 10px rgba(255,255,255,.8)}
@@ -248,6 +251,7 @@ body.lq-talking .lqd.lqblob{opacity:.55}
248
251
  #firststeps .fst span{font-size:12.5px;color:var(--faint);overflow-wrap:anywhere}
249
252
  #firststeps li button{flex:none;padding:5px 12px;font-size:12.5px}
250
253
  #needsbox:empty{display:none}
254
+ #pushout .pushed{margin-top:6px;padding:10px 14px}#pushout .pushed .dh{flex-wrap:wrap;row-gap:2px}#pushout .pushed .dd{font-size:12.5px;line-height:1.5}#pushout>.dd{margin-top:4px}
251
255
  #needsbox{margin:4px 0 12px}
252
256
  #needsbox .nitem{display:flex;flex-direction:column;align-items:stretch;gap:6px;padding:12px 14px;margin-top:8px}
253
257
  #needsbox .nitem .nopt{max-width:720px}
@@ -508,6 +512,8 @@ body.lq-light .cx .lbtn:hover{background:rgba(21,26,33,.05)}
508
512
  body.lq-light .cx.ok .cxb{box-shadow:0 1px 2px rgba(0,0,0,.25);background:radial-gradient(circle at 34% 28%,#FFFFFF 0,#E3E6EA 22%,#9BA2AC 55%,#3A3F46 100%)}
509
513
  .cx.signin .cxb,.cx.signedout .cxb,.cx.error .cxb{opacity:1;box-shadow:inset 0 0 0 2px var(--amber),0 0 10px rgba(242,165,65,.35)}
510
514
  .cxn{font:500 13.5px var(--sans);color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
515
+ .cxa{flex:none;font-size:12px;color:var(--faint);opacity:.55}.cxa.ready{color:var(--bone);opacity:1}
516
+ .cxlg{display:flex;flex-wrap:wrap;align-items:center;gap:4px 6px}.cxh .cxlg .cxlgf{display:inline-flex;align-items:center;gap:6px;margin-left:8px;font:inherit;color:inherit}
511
517
  .cx.ok .cxn{color:var(--bone)}
512
518
  .cxs{margin-left:auto;padding-left:8px;font-size:11.5px;color:var(--faint);white-space:nowrap}
513
519
  .cx.signin .cxs,.cx.signedout .cxs,.cx.error .cxs{color:var(--amber)}
@@ -690,7 +696,8 @@ body.lq-liquid .sset label.check{display:flex;align-items:center;gap:10px;margin
690
696
  body.lq-liquid .sset .note{font-size:12.5px;max-width:70ch}
691
697
  body.lq-liquid .sset .cxh{margin-top:0}
692
698
  /* the Close bar carries the panel's own ground, so what scrolls under it stays clean */
693
- body.lq-liquid.lq-pooled #lqsinkrow{margin:-14px -14px 10px;padding:10px 10px 8px;background:linear-gradient(180deg,rgba(6,8,11,.97) 70%,rgba(6,8,11,0));border-radius:22px 22px 0 0}
699
+ /* top: the panel's padding (main's 16px), so it sticks at the very top edge: scrolled content no longer shows in a strip above it */
700
+ body.lq-liquid.lq-pooled #lqsinkrow{top:-16px;margin:-14px -14px 10px;padding:10px 10px 8px;background:linear-gradient(180deg,rgba(6,8,11,.97) 70%,rgba(6,8,11,0));border-radius:22px 22px 0 0}
694
701
  body.lq-liquid.lq-light.lq-pooled #lqsinkrow{background:linear-gradient(180deg,rgba(246,248,251,.98) 70%,rgba(246,248,251,0))}
695
702
 
696
703
  /* Agents: needs you, at work, finished (folded) */
@@ -751,6 +758,11 @@ body.lq-light .bwthread{border-top-color:rgba(184,106,12,.45)}
751
758
  .pshead{display:flex;flex-direction:column;gap:2px;margin:24px 0 10px}
752
759
  .pshead span{font:600 15px var(--sans);color:var(--bone)}
753
760
  .pshead small{font-size:12.5px;color:var(--faint)}
761
+ /* Marketing's 4-week test: a row a week; the week you're in marked, the ones to come faint */
762
+ .mkwrap{overflow-x:auto}.mktab{width:100%;border-collapse:collapse;font-size:13px}
763
+ .mktab th{font:500 11px var(--sans);letter-spacing:.08em;text-transform:uppercase;color:var(--faint);text-align:left;padding:6px 10px 8px 0;border-bottom:1px solid var(--line);white-space:nowrap}
764
+ .mktab td{padding:9px 10px 9px 0;border-bottom:1px solid var(--line);color:var(--text);vertical-align:baseline}.mktab td b{color:var(--bone)}
765
+ .mktab td.n{font-variant-numeric:tabular-nums}.mktab tr[aria-current] td{color:var(--bone)}.mktab tr[aria-current] td:first-child{box-shadow:inset 2px 0 0 var(--amber);padding-left:8px}
754
766
  .pshelf{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px;align-items:start}
755
767
  body.lq-liquid .pshelf .pcard{position:relative;display:flex;flex-direction:column;gap:10px;margin:0;padding:16px 18px;border-radius:20px;border:1px solid rgba(255,255,255,.08);border-left:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.035)}
756
768
  body.lq-liquid.lq-light .pshelf .pcard{background:rgba(255,255,255,.75);border-color:rgba(21,26,33,.08)}
@@ -777,6 +789,9 @@ body.lq-liquid #panel-tasks .task.open .t{display:block;-webkit-line-clamp:unset
777
789
  #tasklist.onerepo .task .rp,.rcard .task .rp{display:none}
778
790
  body.lq-liquid .rcard{border:1px solid rgba(242,165,65,.3);border-radius:20px;padding:16px 18px 14px;background:rgba(255,255,255,.035);box-shadow:none;margin-top:10px}
779
791
  body.lq-liquid.lq-light .rcard{background:rgba(255,255,255,.75);border-color:rgba(154,82,0,.25)}
792
+ /* a long card's Approve row sticks to the panel's bottom edge (bottom: main's padding) while the card is in view, so it's never scrolled out of reach */
793
+ body.lq-liquid .rcard>.ract{position:sticky;bottom:-16px;z-index:1;margin:8px -18px 0;padding:12px 18px 10px;background:linear-gradient(180deg,rgba(9,11,15,0),rgba(9,11,15,.97) 14px)}
794
+ body.lq-liquid.lq-light .rcard>.ract{background:linear-gradient(180deg,rgba(248,249,251,0),rgba(248,249,251,.96) 14px)}
780
795
  body.lq-liquid .rcard .rhead b{font:600 16px var(--sans);letter-spacing:-.01em}
781
796
  body.lq-liquid .rcard .task{margin-top:6px;padding:9px 12px;border-radius:12px}
782
797
  .rwork{display:flex;align-items:center;gap:9px;margin:10px 0 2px;font-size:13px;color:var(--text)}
@@ -853,7 +868,7 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
853
868
  .bcard .tchat{border-top:1px solid var(--line);border-radius:9px;margin-top:8px}
854
869
  .bcard .bbrief{white-space:pre-line;margin:8px 0;padding:8px 10px;border-left:3px solid var(--green);background:var(--ink3);border-radius:0 8px 8px 0;font-size:13px;color:var(--bone)}
855
870
  @media(max-width:760px){.maprow{flex-direction:column}.detail{width:auto;max-height:none}}
856
- @media(max-width:600px){#panel-tasks .row>#newtask,#screenname,#pagesite{flex:1 1 100%!important}.tabs{overflow-x:auto;scrollbar-width:none;padding:0 8px;gap:0}.tabs::-webkit-scrollbar{display:none}.tab{flex:none;padding:9px 11px}header{padding:12px 14px 8px}main{padding:12px}}
871
+ @media(max-width:600px){#screenname,#pagesite{flex:1 1 100%!important}.tabs{overflow-x:auto;scrollbar-width:none;padding:0 8px;gap:0}.tabs::-webkit-scrollbar{display:none}.tab{flex:none;padding:9px 11px}header{padding:12px 14px 8px}main{padding:12px}}
857
872
  </style></head><body>
858
873
  <div id="liquid" aria-label="Symbiot">
859
874
  <canvas id="lq" aria-hidden="true"></canvas>
@@ -887,11 +902,11 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
887
902
  <button class="tab" data-tab="todo">Todo</button>
888
903
  <button class="tab" data-tab="tasks">Tasks</button>
889
904
  <button class="tab" data-tab="agents">Agents</button>
890
- <button class="tab" data-tab="reports">Reports</button>
905
+ <button class="tab" data-tab="reports">Reports</button><button class="tab" data-tab="marketing">Marketing</button>
891
906
  <button class="tab" data-tab="settings">Settings</button>
892
907
  </div>
893
908
  <main>
894
- <div id="lqsinkrow"><div class="lqwhere"><span id="lqcrumb" class="lqcrumb">Home</span><h2 id="lqtitle" aria-live="polite"></h2><button type="button" class="ghost hidden" id="lqpark"></button></div><button class="iconbtn wide" type="button" id="lqsink" title="close it: it sinks back into the liquid (Esc, or right-click)"><svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 2l8 8M10 2l-8 8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>Close</button></div>
909
+ <div id="lqsinkrow"><div class="lqwhere"><span id="lqcrumb" class="lqcrumb">Home</span><h2 id="lqtitle" aria-live="polite"></h2><button type="button" class="ghost hidden" id="lqpark"></button></div><div class="lqacts"><span id="lqtools"><button class="ghost hidden" type="button" id="pushtasks" title="Write .symbiot/TASKS.md into each repo for your coding agent">Send to repos</button></span><button class="iconbtn wide" type="button" id="lqsink" title="close it: it sinks back into the liquid (Esc, or right-click)"><svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 2l8 8M10 2l-8 8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>Close</button></div></div>
895
910
  <section id="panel-map">
896
911
  <div class="profile muted" id="profile">Mapping your work&hellip;</div>
897
912
  <div class="maprow">
@@ -929,7 +944,6 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
929
944
  <div id="bstream" class="bstream"><canvas id="bsc" aria-hidden="true"></canvas><div id="bsov"></div></div>
930
945
  <div id="boardtalk"></div>
931
946
  <div id="boardawait"></div>
932
- <div id="boardposts"></div>
933
947
  <details id="blist" class="blist"><summary>All of it as a list</summary><div id="board" class="board"></div></details>
934
948
  <div id="boardlinks"></div>
935
949
  </section>
@@ -939,8 +953,7 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
939
953
  <div class="rfoot" id="outfoot" style="display:none"></div>
940
954
  </section>
941
955
  <section id="panel-tasks" class="hidden">
942
- <div class="row"><input id="newtask" placeholder="Add a task..." style="flex:1"><select id="newtaskrepo" title="Which repo this task is for (needed to send it to an agent)" style="flex:0 0 auto;max-width:180px"><option value="">repo…</option></select><button class="act" id="addtask">Add</button><button class="ghost" id="pushtasks" title="Write .symbiot/TASKS.md into each repo for your coding agent">Send to repos</button></div>
943
- <details class="tnhow"><summary>How tasks work</summary><div class="note muted">To give tasks to your agent, use <b>Send to repos</b> &mdash; filter by tag below to choose which. As the agent finishes each one it lands in <b>Awaiting your review</b>: <b>Approve</b> commits it on a branch and opens a PR, <b>&#8630;</b> sends it back. Ticking a task yourself marks it done (it auto-archives). Click <b><i class=ic-chat></i></b> on a task to ask questions about it.</div></details>
956
+ <details class="tnhow"><summary>How tasks work</summary><div class="note muted">To add a task, tell Symbiot in the bar at the bottom: it files it under the right project. To give tasks to your agent, use <b>Send to repos</b> at the top, beside Close &mdash; filter by tag below to choose which. As the agent finishes each one it lands in <b>Awaiting your review</b>: <b>Approve</b> commits it on a branch and opens a PR, <b>&#8630;</b> sends it back. Ticking a task yourself marks it done (it auto-archives). Click <b><i class=ic-chat></i></b> on a task to ask questions about it.</div></details>
944
957
  <div id="needsbox"></div>
945
958
  <div id="pushout"></div>
946
959
  <div id="reviewout"></div>
@@ -949,7 +962,7 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
949
962
  <div id="tasklist"></div>
950
963
  </section>
951
964
  <section id="panel-agents" class="hidden">
952
- <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="iconbtn" id="agentsrefresh" style="margin-left:auto" title="Refresh" aria-label="Refresh"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M13 8a5 5 0 1 1-1.5-3.6M13 2.5V5h-2.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>
965
+ <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="iconbtn" id="agentsrefresh" title="Refresh" aria-label="Refresh"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M13 8a5 5 0 1 1-1.5-3.6M13 2.5V5h-2.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>
953
966
  <div id="agentsmsg"></div>
954
967
  <div id="laneslist"></div>
955
968
  <div id="agentslist"></div>
@@ -959,6 +972,13 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
959
972
  <div id="reportslist"></div>
960
973
  <div id="reportview"></div>
961
974
  </section>
975
+ <section id="panel-marketing" class="hidden">
976
+ <div class="row"><span class="muted" style="flex:1">What you post about your work, and who answers: your drafts, the replies to them, and the 4-week test of whether posting brings anyone. Symbiot never posts: you do, on LinkedIn.</span></div>
977
+ <div id="mkmsg"></div>
978
+ <div id="boardposts"></div>
979
+ <div id="mkreplies"></div>
980
+ <div id="mktest"></div>
981
+ </section>
962
982
  <section id="panel-drift" class="hidden">
963
983
  <div class="row"><span class="muted">What's out of sync, stuck or at risk across your repos — local git facts.</span>
964
984
  <label class="muted swl" style="margin-left:auto"><input type="checkbox" class="sw" id="driftfetch"> Fetch latest</label>
@@ -1091,9 +1111,10 @@ $('panel-tasks').classList.toggle('hidden',!isTasks);
1091
1111
  $('panel-drift').classList.toggle('hidden',!isDrift);
1092
1112
  $('panel-agents').classList.toggle('hidden',!isAgents);
1093
1113
  $('panel-reports').classList.toggle('hidden',tab!=='reports');if(tab==='reports')loadReports();
1114
+ $('panel-marketing').classList.toggle('hidden',tab!=='marketing');if(tab==='marketing')loadMarketing();
1094
1115
  if(isRun){$('what').textContent=tab;wuHead();WUTEXT='';$('out').textContent=WUEMPTY;$('out').classList.add('muted');$('copy').classList.add('hidden');$('outfoot').style.display='none';if(tab==='week')showLatestWeek();}
1095
1116
  if(isMap&&!mapLoaded)loadMap();
1096
- if(isTasks){fillTaskRepos();loadTasks();}
1117
+ if(isTasks)loadTasks();
1097
1118
  if(isDrift&&!driftLoaded)loadDrift();
1098
1119
  if(isAgents)loadAgents(); else stopAgentsPoll();
1099
1120
  lqTitle();if(isTasks&&TFILTER.repo)loadParked(lqTitle);}
@@ -1134,6 +1155,9 @@ var tf=typeof TFILTER!=='undefined'&&TFILTER||{},arch=typeof TARCH!=='undefined'
1134
1155
  if(proj)name=lqProjName(proj);var parked=!!proj&&PARKED.repos.indexOf(proj)>=0;
1135
1156
  if(c)c.textContent=proj?'Projects':'Home';t.innerHTML=esc(name)+(parked?"<span class='lqpk'>parked</span>":'');
1136
1157
  var pk=$('lqpark');if(pk){pk.classList.toggle('hidden',!proj);pk.textContent=parked?'Unpark':'Park this project';pk.title=parked?'let its tasks start agent runs again':'stop its tasks starting agent runs, until you unpark it';pk.setAttribute('data-repo',proj);}
1158
+ // the open panel's own Refresh sits beside Close, not under it on the panel's top line
1159
+ var tl=$('lqtools');if(tl&&tl.appendChild)['agents','reports'].forEach(function(p){var b=$(p+'refresh'),row=$('panel-'+p);row=row&&row.querySelector('.row');if(!b)return;if(p===current){if(b.parentNode!==tl)tl.appendChild(b);}else if(b.parentNode===tl&&row&&row.appendChild)row.appendChild(b);});
1160
+ var pt=$('pushtasks');if(pt&&pt.classList)pt.classList.toggle('hidden',current!=='tasks'||!!arch);
1137
1161
  try{document.title=name+' \\u00b7 Symbiot';}catch(e){}}
1138
1162
  function lqPark(repo,on,then,path){api('/api/lanes/park',{repo:repo,path:path||'',on:on}).then(function(r){if(r&&r.error){if(then)then(r.error);return;}loadParked(function(){lqTitle();if(then)then('');if(LQ.scene==='work')lqLoadWork();});});}
1139
1163
  function loadParked(then){api('/api/lanes/parked').then(function(p){PARKED={repos:(p&&p.repos)||[],paths:(p&&p.paths)||[]};if(then)then();}).catch(function(){if(then)then();});}
@@ -1380,7 +1404,7 @@ else if(ur)h+="<div class='muted' style='margin:6px 0'><span class='err'>Unrelea
1380
1404
  // The version is already released (its v* tag exists, or npm has it) and these changes keep it:
1381
1405
  // Approve bumps it in the PR too, a patch unless you pick otherwise.
1382
1406
  var bo=r.bumpOffer;if(bo&&r.files.length)h+="<div class='row' style='margin:6px 0;font-size:12px'><span class='muted'>Version</span><select class='bumpsel' title='bump the version in package.json (and the lockfile) in this PR' style='flex:0 0 auto;width:auto'><option value='patch'>bump to "+esc(bo.patch)+" (patch)</option><option value='minor'>bump to "+esc(bo.minor)+" (minor)</option><option value=''>keep "+esc(bo.version)+"</option></select></div>";
1383
- h+="<div class='row'>"+(pq&&r.path?"<button class='act answerq' data-path='"+esc(r.path).replace(/'/g,'&#39;')+"'>Answer its question"+(pq>1?"s":"")+"</button>":"")+"<button class='"+(pd?'ghost':'act')+" approve'"+(pd?" data-partly='1'":"")+(r.path&&!r.running?"":" disabled")+(r.running?" title='the agent is still editing this repo'>"+(r.untasked?"Approve changes without a task":"Approve")+" &middot; agent still working":r.untasked?" data-untasked='1' title='commit on a branch, push and open a PR, without a task'>Approve changes without a task &rarr; PR":" title='commit on a branch, push, open a PR, then archive'>Approve &rarr; "+(r.files.length?"PR":"archive"))+"</button>"+(r.files.length?"<button class='ghost showdiff'>Show diff</button>":"")+"<label title='Queue GitHub auto-merge so this PR lands once its CI checks pass. Needs Allow auto-merge on the repo.' style='margin-left:auto;font-size:12px;color:var(--faint);display:flex;align-items:center;gap:6px'><input type='checkbox' class='amtoggle' style='width:auto'"+(r.autoMerge?" checked":"")+"> auto-merge on green CI</label></div><div class='rdiff hidden'></div></div>";});
1407
+ h+="<div class='row ract'>"+(pq&&r.path?"<button class='act answerq' data-path='"+esc(r.path).replace(/'/g,'&#39;')+"'>Answer its question"+(pq>1?"s":"")+"</button>":"")+"<button class='"+(pd?'ghost':'act')+" approve'"+(pd?" data-partly='1'":"")+(r.path&&!r.running?"":" disabled")+(r.running?" title='the agent is still editing this repo'>"+(r.untasked?"Approve changes without a task":"Approve")+" &middot; agent still working":r.untasked?" data-untasked='1' title='commit on a branch, push and open a PR, without a task'>Approve changes without a task &rarr; PR":" title='commit on a branch, push, open a PR, then archive'>Approve &rarr; "+(r.files.length?"PR":"archive"))+"</button>"+(r.files.length?"<button class='ghost showdiff'>Show diff</button>":"")+"<label title='Queue GitHub auto-merge so this PR lands once its CI checks pass. Needs Allow auto-merge on the repo.' style='margin-left:auto;font-size:12px;color:var(--faint);display:flex;align-items:center;gap:6px'><input type='checkbox' class='amtoggle' style='width:auto'"+(r.autoMerge?" checked":"")+"> auto-merge on green CI</label></div><div class='rdiff hidden'></div></div>";});
1384
1408
  el.innerHTML=h;
1385
1409
  el.querySelectorAll('.rcard').forEach(function(card){var repo=card.getAttribute('data-repo');
1386
1410
  card.querySelectorAll('.task').forEach(function(row){wireAsk(row,row.getAttribute('data-id'));});
@@ -1459,6 +1483,13 @@ api('/api/agents/skip',{path:a.path,text:s.text}).then(function(r){if(!r||r.erro
1459
1483
  // A step of yours an answer picked (👤 You): the next run waits for it (agents.mjs waitingFor)
1460
1484
  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";
1461
1485
  return "<div class='aq'><h4><i class=ic-pause></i> Waiting on your step</h4><div class='qt'><i class=ic-person></i> "+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>";}
1486
+ // A run that ended waiting on you (agents.mjs needsOf): your OK for what it would send or
1487
+ // change, or a step only you can do, with what to check first. Answered as on Home.
1488
+ function needsHtml(a){var n=a.needs;if(!n||n.waiting)return '';var ok=n.kind==='approve';
1489
+ return "<div class='aq'><h4><i class=ic-person></i> "+(ok?"Waits for your OK":"Needs you")+"</h4><div class='qt'>"+esc(n.what)+"</div>"+(n.check?"<div class='qc'><b>Check first:</b> "+esc(n.check)+"</div>":"")
1490
+ +"<div class='row'><button class='act ngo' data-id='"+esc(a.id)+"' title='"+(ok?"its agent goes ahead with it":"its agent checks it worked and carries on")+"'>"+(ok?"Go ahead":"Done it")+"</button><button class='ghost nskip' data-id='"+esc(a.id)+"' title='it stops asking'>Skip</button></div></div>";}
1491
+ function wireNeeds(el){el.querySelectorAll('.ngo,.nskip').forEach(function(btn){btn.addEventListener('click',function(){var a=agentById(btn.getAttribute('data-id'));if(!a)return;btn.disabled=true;var msg=document.getElementById('agentsmsg');
1492
+ api('/api/home/answer',{id:'needs:'+a.id,pick:btn.classList.contains('ngo')?0:1}).then(function(r){msg.innerHTML=r&&!r.error?"<div class='note ok'>"+esc(a.name)+": "+esc(r.said||'Done.')+"</div>":"<div class='note err'>"+esc((r&&r.error)||'failed')+"</div>";loadAgents();}).catch(function(e){btn.disabled=false;msg.innerHTML="<div class='note err'>"+esc(String((e&&e.message)||e))+"</div>";});});});}
1462
1493
  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;
1463
1494
  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>";});});});}
1464
1495
  // What a run left for Symbiot's memory (.symbiot/REMEMBER.json, handback.mjs): nothing goes in until Remember
@@ -1515,7 +1546,7 @@ function loadAgents(){loadLanes();loadParked(agentsDraw);}
1515
1546
  // Agents, in order: what needs you (a question, a step of yours), what's at work, and
1516
1547
  // what's finished, folded away (open stays open across refreshes).
1517
1548
  var AGDONEOPEN=false;
1518
- function agentGroups(list,fn){var needs=[],run=[],done=[];list.forEach(function(a){if(nQs(a)||a.waiting)needs.push(a);else if(a.status==='running')run.push(a);else done.push(a);});
1549
+ function agentGroups(list,fn){var needs=[],run=[],done=[];list.forEach(function(a){if(nQs(a)||a.waiting||a.needs)needs.push(a);else if(a.status==='running')run.push(a);else done.push(a);});
1519
1550
  var g=function(t,xs){return xs.length?"<div class='tgroup agg'>"+t+" <span class='tcount'>"+xs.length+"</span></div>"+xs.map(fn).join(''):'';};
1520
1551
  return g('Needs you',needs)+g('At work',run)+(done.length?"<details class='agdone'"+(AGDONEOPEN?' open':'')+"><summary class='tgroup agg'>Finished <span class='tcount'>"+done.length+"</span></summary>"+done.map(fn).join('')+"</details>":'');}
1521
1552
  function agentsDraw(){api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
@@ -1529,6 +1560,7 @@ if(a.fromHeld)st+=" &middot; started on the tasks held for the last run";
1529
1560
  if(a.earlier)st+=" &middot; "+(a.status==='running'?"started outside this window":"ran before Symbiot last started");
1530
1561
  if(a.waiting)st+=" &middot; <span style='color:var(--amber)'>waiting on your step</span>";
1531
1562
  if(nQs(a))st+=" &middot; <span style='color:var(--amber)'>needs your answers</span>";
1563
+ if(a.needs&&!a.needs.waiting)st+=" &middot; <span style='color:var(--amber)'>"+(a.needs.kind==='approve'?"waits for your OK":"needs you")+"</span>";
1532
1564
  if(a.remember&&a.remember.length)st+=" &middot; <span style='color:var(--amber)'>found "+a.remember.length+" thing"+(a.remember.length>1?"s":"")+" to remember</span>";
1533
1565
  if(a.held!=null){var hn=a.held.length,ht=a.held.map(function(t){return "&bull; "+esc(t).replace(/'/g,'&#39;');}).join('&#10;');
1534
1566
  ht+=(hn?'&#10;&#10;':'')+(a.status==='running'?"Sent while this agent was running. They wait in .symbiot/TASKS.next.md, replace TASKS.md when it finishes (keeping its ticks), and an agent starts on them then.":"They wait in .symbiot/TASKS.next.md for the agent running in this folder to finish. After that, an agent starts on them the next time the Tasks tab checks this repo, or send again.");
@@ -1536,7 +1568,7 @@ st+=" &middot; <span style='color:var(--amber);cursor:help' title='"+ht+"'><i cl
1536
1568
  // a repo's run can be parked from here in one click: its tasks start no runs until it's unparked
1537
1569
  var pk=!/\\/drafts\\/act-[^/]*$/.test(String(a.path||''))&&a.path,parked=pk&&PARKED.paths.indexOf(a.path)>=0;
1538
1570
  if(parked)st+=" &middot; <span style='color:var(--amber)'>parked: no new runs</span>";
1539
- var b="<div class='dh'><span class='orb "+cls+"'></span><span class='dn'>"+esc(String(a.name||'').split('**').join(''))+"</span><span class='dd'>"+st+"</span>"+(pk?"<button type='button' class='ghost apark' data-on='"+(parked?'0':'1')+"' title='"+(parked?"let its tasks start agent runs again":"stop its tasks starting agent runs, until you unpark it")+"'>"+(parked?'Unpark':'Park')+"</button>":"")+"</div>";
1571
+ var b="<div class='dh'><span class='orb "+cls+"'></span><span class='dn'>"+esc(String(a.name||'').split('**').join('').replace(/^(Agent:\s*)What['’]s needed:\s*/i,'$1'))+"</span><span class='dd'>"+st+"</span>"+(pk?"<button type='button' class='ghost apark' data-on='"+(parked?'0':'1')+"' title='"+(parked?"let its tasks start agent runs again":"stop its tasks starting agent runs, until you unpark it")+"'>"+(parked?'Unpark':'Park')+"</button>":"")+"</div>";
1540
1572
  b+=workHtml(a);
1541
1573
  var ch=a.changed;
1542
1574
  if(ch&&(ch.dirty||ch.stat||(ch.commits&&ch.commits.length))){
@@ -1548,12 +1580,12 @@ if(ch&&(ch.dirty||ch.stat||(ch.commits&&ch.commits.length))){
1548
1580
  }else if(a.status==='done'){
1549
1581
  b+="<div class='changed muted'>No file changes detected (the agent may have only planned or asked).</div>";
1550
1582
  }
1551
- b+=waitHtml(a)+askHtml(a)+factsHtml(a);
1583
+ b+=waitHtml(a)+needsHtml(a)+askHtml(a)+factsHtml(a);
1552
1584
  b+=a.work?("<details class='wkraw'><summary>what it said, in full</summary><pre class='alogout'>"+esc((a.tail&&a.tail.trim())||'(nothing yet)')+"</pre></details>"):("<pre class='alogout'>"+esc((a.tail&&a.tail.trim())||'(waiting for output…)')+"</pre>");
1553
1585
  return "<div class='agent' data-i='"+list.indexOf(a)+"'>"+b+"</div>";};
1554
1586
  el.innerHTML=agentGroups(list,agentBlock);var dn=el.querySelector('.agdone');if(dn)dn.addEventListener('toggle',function(){AGDONEOPEN=dn.open;});
1555
1587
  el.querySelectorAll('.alogout').forEach(function(p){p.scrollTop=p.scrollHeight;});
1556
- restoreDrafts(el);wireAsks(el);wireWaits(el);wireFacts(el);lqLight();
1588
+ restoreDrafts(el);wireAsks(el);wireWaits(el);wireNeeds(el);wireFacts(el);lqLight();
1557
1589
  el.querySelectorAll('.apark').forEach(function(btn){btn.addEventListener('click',function(){var g=AGENTLIST[+btn.closest('.agent').getAttribute('data-i')];if(!g)return;btn.disabled=true;lqPark('',btn.getAttribute('data-on')==='1',function(err){btn.disabled=false;$('agentsmsg').innerHTML=err?"<div class='note err'>"+esc(err)+"</div>":'';loadAgents();},g.path);});});
1558
1590
  stopAgentsPoll();if(anyRunning&&current==='agents')agentsTimer=setTimeout(loadAgents,2000);});}
1559
1591
  function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos&hellip;</div>";
@@ -1568,16 +1600,15 @@ var clean=repos.filter(function(r){return !r.flags.length;}).map(function(r){ret
1568
1600
  if(clean.length)h+="<div class='muted' style='margin-top:10px'>clean: "+esc(clean.join(", "))+"</div>";
1569
1601
  if(!repos.length)h+="<div class='muted'>No repos found under "+esc(d.base||"your home folder")+".</div>";
1570
1602
  out.innerHTML=h;});}
1571
- function fillTaskRepos(){var sel=document.getElementById('newtaskrepo');if(!sel||!GRAPH)return;var cur=sel.value;var names=GRAPH.nodes.filter(function(n){return n.type==='repo'||n.type==='folder';}).map(function(n){return n.label;}).sort();sel.innerHTML="<option value=''>repo…</option>"+names.map(function(n){return "<option value='"+esc(n)+"'"+(n===cur?" selected":"")+">"+esc(n)+"</option>";}).join("");}
1572
- function addTaskUI(){var i=document.getElementById('newtask');var v=(i.value||'').trim();if(!v)return;var repo=(document.getElementById('newtaskrepo')||{}).value||'';api('/api/tasks/add',{text:v,repo:repo}).then(function(){i.value='';TFILTER={type:'',repo:''};loadTasks();});}
1573
1603
  function pushTasksUI(){var o=document.getElementById('pushout');var btn=document.getElementById('pushtasks');btn.disabled=true;
1574
1604
  o.innerHTML="<div class='muted' style='margin-top:10px'><span class='dot-c' style='background:var(--amber)'></span> Writing .symbiot/TASKS.md into your repos&hellip;</div>";
1575
1605
  api('/api/tasks/push',{type:TFILTER.type||'',repo:TFILTER.repo||''}).then(function(r){
1576
- if(r.empty){btn.disabled=false;o.innerHTML="<div class='muted' style='margin-top:10px'>No open tasks to send. Tick ideas in a repo review, or add tasks above.</div>";return;}
1577
- var n=(r.written||[]).length;var opens=[];var h="<div class='drift' style='margin-top:10px'><div class='dh'><span class='dot-c'></span><span class='dn'>Done &mdash; wrote "+n+" file"+(n===1?"":"s")+"</span></div>";
1578
- if(n){h+="<ul>";r.written.forEach(function(w){h+="<li class='info'>&#10003; <b>"+esc(w.name)+"</b> <span class='ev'>"+esc(w.file)+" ("+w.count+" task"+(w.count===1?"":"s")+")</span>"+(w.held?" <span class='ev'><i class=ic-pause></i> held: an agent is still running there, so these land when it finishes</span>":"")+"</li>";});h+="</ul>";}
1579
- if(r.unresolved&&r.unresolved.length){var names=r.unresolved.map(function(u){return u.name;}).join(", ");var hasNoRepo=r.unresolved.some(function(u){return u.name==='(no repo)';});h+="<div class='dd' style='margin-top:6px'><i class=ic-warn></i> not sent: "+esc(names)+". "+(hasNoRepo?"Pick a repo in the dropdown next to <b>Add</b> so the task has somewhere to go.":"That repo isn't in the map &mdash; add its folder in Settings.")+"</div>";}
1580
- if(r.handoff&&r.written&&r.written.length){opens=r.written.map(function(w){return api('/api/open',{path:w.path}).then(function(x){if(x&&x.blocked){var d=document.createElement('div');d.className='dd';d.innerHTML="<i class=ic-pause></i> <b>"+esc(w.name)+"</b>: "+esc(x.note)+" <button class='ghost' style='padding:2px 8px;font-size:12px'>Start it anyway</button>";d.querySelector('button').addEventListener('click',function(){this.disabled=true;api('/api/open',{path:w.path,force:true}).then(function(y){d.innerHTML=y&&y.opened?"&#10003; Started an agent in <b>"+esc(w.name)+"</b>.":"<i class=ic-warn></i> <b>"+esc(w.name)+"</b>: "+(y&&y.busy?"an agent is already running there.":"it didn&#39;t start. Check the command in Settings.");});});o.appendChild(d);}if(x&&x.busy){var d=document.createElement('div');d.className='dd';d.innerHTML="<i class=ic-warn></i> <b>"+esc(w.name)+"</b> already has an agent running, so another wasn&#39;t started. "+(x.auto?"Its new tasks are held, and an agent starts on them when it finishes.":"Its new tasks are held until it finishes. After that, an agent starts on them the next time this tab checks the repo, or send again.");o.appendChild(d);}}).catch(function(){});});h+="<div class='dd' style='margin-top:8px'><i class=ic-bot></i> Handed "+n+" repo(s) to your agent &mdash; opening the <b>Agents</b> tab to watch it work&hellip;</div>";setTimeout(function(){setTab('agents');},500);}
1606
+ if(r.empty){btn.disabled=false;o.innerHTML="<div class='muted' style='margin-top:10px'>No open tasks to send. Tell Symbiot what to do in the bar at the bottom, or tick ideas in a repo review.</div>";return;}
1607
+ var n=(r.written||[]).length;var opens=[];
1608
+ // one line, its paths on hover: the review cards below it stay in view
1609
+ var h="<div class='drift pushed'><div class='dh'><span class='dot-c'></span><span class='dn'>Sent to "+n+" repo"+(n===1?"":"s")+"</span><span class='dd'>"+(r.written||[]).map(function(w){return "<span title='"+esc(w.file).replace(/'/g,'&#39;')+"'>&#10003; <b>"+esc(w.name)+"</b> ("+w.count+" task"+(w.count===1?"":"s")+(w.held?", <i class=ic-pause></i> held till its running agent finishes":"")+")</span>";}).join(" &middot; ")+"</span></div>";
1610
+ if(r.unresolved&&r.unresolved.length){var names=r.unresolved.map(function(u){return u.name;}).join(", ");var hasNoRepo=r.unresolved.some(function(u){return u.name==='(no repo)';});h+="<div class='dd' style='margin-top:6px'><i class=ic-warn></i> not sent: "+esc(names)+". "+(hasNoRepo?"Tell Symbiot in the bar at the bottom which project it&#39;s for, so it has somewhere to go.":"That repo isn't in the map &mdash; add its folder in Settings.")+"</div>";}
1611
+ if(r.handoff&&r.written&&r.written.length){opens=r.written.map(function(w){return api('/api/open',{path:w.path}).then(function(x){if(x&&x.blocked){var d=document.createElement('div');d.className='dd';d.innerHTML="<i class=ic-pause></i> <b>"+esc(w.name)+"</b>: "+esc(x.note)+" <button class='ghost' style='padding:2px 8px;font-size:12px'>Start it anyway</button>";d.querySelector('button').addEventListener('click',function(){this.disabled=true;api('/api/open',{path:w.path,force:true}).then(function(y){d.innerHTML=y&&y.opened?"&#10003; Started an agent in <b>"+esc(w.name)+"</b>.":"<i class=ic-warn></i> <b>"+esc(w.name)+"</b>: "+(y&&y.busy?"an agent is already running there.":"it didn&#39;t start. Check the command in Settings.");});});o.appendChild(d);}if(x&&x.busy){var d=document.createElement('div');d.className='dd';d.innerHTML="<i class=ic-warn></i> <b>"+esc(w.name)+"</b> already has an agent running, so another wasn&#39;t started. "+(x.auto?"Its new tasks are held, and an agent starts on them when it finishes.":"Its new tasks are held until it finishes. After that, an agent starts on them the next time this tab checks the repo, or send again.");o.appendChild(d);}}).catch(function(){});});h+="<div class='dd'><i class=ic-bot></i> Handed "+n+" repo"+(n===1?"":"s")+" to your agent &mdash; opening the <b>Agents</b> tab to watch it work&hellip;</div>";setTimeout(function(){setTab('agents');},500);}
1581
1612
  else{h+="<div class='dd' style='margin-top:8px'>Set an <b>agent command</b> in Settings to auto-run it on send (and watch it in the Agents tab). For now, tell your agent: <b>“Read .symbiot/TASKS.md and implement the unchecked items.”</b></div>";}
1582
1613
  h+="</div>";
1583
1614
  o.innerHTML=h;
@@ -1671,10 +1702,17 @@ function escQ(t){return esc(t).replace(/'/g,'&#39;');}
1671
1702
  // Connections: a column per kind of site; each site a bead in the look: solid when
1672
1703
  // linked, amber when it wants you to sign in, hollow when it isn't linked. Link,
1673
1704
  // check (↻) and Unlink show on hover (always on touch).
1674
- function linksHtml(d,board){var h="<div class='cxh'><span>Connections</span><small>"+(d.linked?"What Symbiot watches for you. Link more to see what arrives there, here and in Week and Standup.":"Link your work to see what arrives there, here and in Week and Standup.")+"</small></div>";
1705
+ // The plug marks the sites your agent's runs can use too, before you link them: only
1706
+ // those Claude has a connector for, and only with Claude Code as your agent (agents.mjs
1707
+ // linkReach). Faint until you've connected it in claude.ai.
1708
+ function reachOf(d,id){var r=d.reach,s=r&&r.claude&&r.sites&&r.sites[id];return s||null;}
1709
+ function linksHtml(d,board){var anyR=d.items.some(function(it){return reachOf(d,it.id);});
1710
+ var h="<div class='cxh'><span>Connections</span><small>"+(d.linked?"What Symbiot watches for you. Link more to see what arrives there, here and in Week and Standup.":"Link your work to see what arrives there, here and in Week and Standup.")+"</small>"
1711
+ +(anyR?"<small class='cxlg'><i class='ic-plug cxa ready' aria-hidden='true'></i> your agent&#39;s runs can use it too, through Claude&#39;s connector for it <span class='cxlgf'><i class='ic-plug cxa' aria-hidden='true'></i> faint: once you connect it in claude.ai (Settings &rarr; Connectors)</span></small>":"")+"</div>";
1675
1712
  if(d.error)h+="<div class='note err'>"+esc(d.error)+"</div>";
1676
1713
  h+="<div class='cxg'>"+d.groups.map(function(g){return "<div class='cxc'><div class='cxl'>"+esc(g)+"</div>"+d.items.filter(function(x){return x.group===g;}).map(function(it){var on=it.state!=='off';
1677
- return "<div class='lnk cx "+escQ(it.state)+"' data-id='"+escQ(it.id)+"'><button type='button' class='lbtn' title='"+escQ(it.note||(on?'open '+it.url+' to sign in again':'sign in at '+it.url+' and link it'))+"'><span class='cxb' aria-hidden='true'></span><span class='cxn'>"+esc(it.name)+"</span><span class='cxs'>"+(on?esc(LINKWORD[it.state]||''):'Link')+"</span></button>"+
1714
+ var rc=reachOf(d,it.id),rt=rc?(rc.ready?" Your agent's runs can use it too, through Claude's "+rc.connector.replace(/^claude\.ai\s+/i,'')+" connector.":" Your agent's runs can use it too once you connect "+rc.name+" in claude.ai → Settings → Connectors (only you can sign in there)."):'';
1715
+ return "<div class='lnk cx "+escQ(it.state)+"' data-id='"+escQ(it.id)+"'><button type='button' class='lbtn' title='"+escQ(String(it.note||(on?'open '+it.url+' to sign in again':'sign in at '+it.url+' and link it')).replace(/\.?\s*$/,rt?'.':'')+rt)+"'><span class='cxb' aria-hidden='true'></span><span class='cxn'>"+esc(it.name)+"</span>"+(rc?"<i class='ic-plug cxa"+(rc.ready?" ready":"")+"' role='img' aria-label='"+escQ(rc.ready?"agent runs can use it too":"agent runs can use it once connected in claude.ai")+"'></i>":"")+"<span class='cxs'>"+(on?esc(LINKWORD[it.state]||''):'Link')+"</span></button>"+
1678
1716
  (on?"<button type='button' class='lmore' title='check it now' aria-label='Check "+escQ(it.name)+" now'>&#8635;</button><button type='button' class='lrm' title='unlink: stop watching it and stop trusting it'>Unlink</button>":"")+"</div>";}).join('')+"</div>";}).join('')+"</div>";return h;}
1679
1717
  function linksOut(html){var o=document.getElementById('linksmsg');if(o)o.innerHTML=html;var b=document.getElementById('boardmsg');if(b&&LINKS&&!LINKS.linked)b.innerHTML=html;}
1680
1718
  function renderLinks(){if(!LINKS)return;['links','boardlinks'].forEach(function(id){var el=document.getElementById(id);if(!el)return;
@@ -1741,7 +1779,7 @@ function scanLine(s){return 'Mapping your work… '+s.phase+(s.total?' '+s.done+
1741
1779
  function loadMap(){var p=document.getElementById("profile");p.textContent="Mapping your work...";document.getElementById("graph").innerHTML="";sel=null;hideDetail();hideReview();var done=false;
1742
1780
  function poll(){if(done)return;api('/api/scan').then(function(s){if(done)return;if(s&&s.active&&s.phase)p.textContent=scanLine(s);setTimeout(poll,600);}).catch(function(){});}
1743
1781
  setTimeout(poll,400);
1744
- api("/api/map").then(function(g){done=true;mapLoaded=true;if(!g.nodes||!g.nodes.length){p.textContent="No git repositories found under your home folder.";return;}GRAPH=g;fillTaskRepos();layout(g.nodes,g.edges);lmBuild(g);view={k:1,x:0,y:0};p.innerHTML=profileLine(g)+(g.stats&&g.stats.partial?" &middot; <span class='err'>partial &mdash; the scan hit its time limit</span>":"");var af=$('allowfiles');if(af)af.addEventListener('click',function(){SymbiotAndroid.storage();});var ut=$('usetermux');if(ut)ut.addEventListener('click',function(){SymbiotAndroid.openTermux();});render();});}
1782
+ api("/api/map").then(function(g){done=true;mapLoaded=true;if(!g.nodes||!g.nodes.length){p.textContent="No git repositories found under your home folder.";return;}GRAPH=g;layout(g.nodes,g.edges);lmBuild(g);view={k:1,x:0,y:0};p.innerHTML=profileLine(g)+(g.stats&&g.stats.partial?" &middot; <span class='err'>partial &mdash; the scan hit its time limit</span>":"");var af=$('allowfiles');if(af)af.addEventListener('click',function(){SymbiotAndroid.storage();});var ut=$('usetermux');if(ut)ut.addEventListener('click',function(){SymbiotAndroid.openTermux();});render();});}
1745
1783
  // The Android app calls this when you come back having allowed file access, so
1746
1784
  // the map rescans instead of staying empty.
1747
1785
  window.symbiotStorageGranted=function(){if(current==='map')loadMap();else mapLoaded=false;};
@@ -1886,8 +1924,9 @@ function copyPost(text,done){function fallback(){var t=document.createElement('t
1886
1924
  if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(text).then(function(){done(true);},fallback);else fallback();}
1887
1925
  function renderPosts(){var p=POSTS,el=$('boardposts');if(!el||!p)return;var L=p.labels||{},h='';
1888
1926
  // it can't draft yet (no AI, or neither LinkedIn linked nor examples in voice.md): one line, not a block a new user meets first
1889
- if(!p.posts.length&&p.canDraft===false){var need=[];if(!p.connected)need.push('an AI is connected (Settings)');if(!p.linkedin&&!p.voice.count)need.push('LinkedIn is linked (below), or <code>'+esc(p.voice.file)+'</code> has a few of your posts');
1890
- el.innerHTML="<div class='muted' style='margin-top:12px;font-size:13px'>Posts: Symbiot drafts your week&#39;s LinkedIn posts here once "+need.join(', and ')+".</div>";return;}
1927
+ if(!p.posts.length&&p.canDraft===false){var need=[];if(!p.connected)need.push('an AI is connected (Settings)');if(!p.linkedin&&!p.voice.count)need.push('LinkedIn is linked, or <code>'+esc(p.voice.file)+'</code> has a few of your posts');
1928
+ el.innerHTML="<div class='pshead'><span>Drafts to post</span><small>Symbiot drafts your week&#39;s LinkedIn posts from your git, in your voice, once "+need.join(', and ')+".</small></div>"+(!p.linkedin?"<div class='row'><button class='ghost plink' title='sign in to LinkedIn once in Symbiot&#39;s browser: it reads your posts for your voice, and replies to them show here'>Link LinkedIn</button></div>":"");
1929
+ var pl=el.querySelector('.plink');if(pl)pl.addEventListener('click',function(){pl.disabled=true;api('/api/links/link',{id:'linkedin'}).then(function(r){pl.disabled=false;mkMsg(r&&!r.error?'ok':'err',r&&!r.error?"Opened LinkedIn in Symbiot&#39;s browser. Sign in as you normally do, then close that window.":esc((r&&r.error)||'failed'));});});return;}
1891
1930
  if(p.posts.length){h+="<div class='pshead'><span>Drafts to post</span><small>"+p.posts.length+" waiting on you &middot; Approve copies one and opens LinkedIn, where you post it yourself</small></div><div class='pshelf'>";
1892
1931
  h+=p.posts.map(function(x){var ed=Object.prototype.hasOwnProperty.call(PEDIT,x.id);
1893
1932
  return "<div class='rcard pcard"+(ed?" editing":"")+"' data-id='"+escQ(x.id)+"'><div class='rhead'><b>"+esc(L[x.kind]||x.kind)+"</b><span class='muted'>LinkedIn &middot; drafted "+agoTxt(x.drafted)+(x.edited?" &middot; edited":"")+"</span></div>"
@@ -1944,6 +1983,29 @@ POSTS.posts=r.posts;renderPosts();var said=[];if(r.retried)said.push("The first
1944
1983
  var vb=el.querySelector('.pvoice');if(vb)vb.addEventListener('click',function(){vb.disabled=true;postsMsg('','');vb.textContent='Reading LinkedIn…';
1945
1984
  api('/api/posts/voice',{confirmed:true}).then(function(r){vb.disabled=false;vb.textContent='Fill from LinkedIn';if(!r||r.error){postsMsg('err',esc((r&&r.error)||'failed'));return;}
1946
1985
  postsMsg('ok',"&#10003; Added "+r.added+" of your LinkedIn posts: "+r.total+" in <code>"+esc(r.file)+"</code>. Read them over, and delete any that don&#39;t sound like you.");loadPostsUI();}).catch(function(e){vb.disabled=false;vb.textContent='Fill from LinkedIn';postsMsg('err',esc(String((e&&e.message)||e)));});});}
1986
+ // ---- Marketing: what you post about your work (post.mjs), who answers, and the 4-week test ----
1987
+ // Off the Dashboard, which shows only what you watch. Its orb shows on Home once posts
1988
+ // can be drafted, or are (home.mjs marketing).
1989
+ var MKT=null;
1990
+ function mkMsg(cls,html){var o=$('mkmsg');if(o)o.innerHTML=html?"<div class='note "+cls+"'>"+html+"</div>":'';}
1991
+ function loadMarketing(){loadPostsUI();api('/api/marketing').then(function(d){if(d&&!d.error){MKT=d;renderMarketing();}});}
1992
+ var MKDAY=function(s){var d=new Date(s+'T00:00:00');return isNaN(d)?s:d.toLocaleDateString(undefined,{day:'numeric',month:'short'});};
1993
+ function renderMarketing(){var d=MKT,r=$('mkreplies'),t=$('mktest');if(!d||!r||!t)return;var rs=d.replies||[];
1994
+ r.innerHTML="<div class='pshead'><span>Replies</span><small>"+(!d.linkedin?"Link LinkedIn, and comments on and mentions of your posts show here, marked when they may be a customer.":rs.length?"On LinkedIn in the last 30 days. Draft a reply leaves one in the comment box for you to post.":"None on LinkedIn in the last 30 days. Comments on and mentions of your posts show here, marked when they may be a customer.")+"</small></div>"
1995
+ +rs.map(function(n){return "<div class='task' data-id='"+escQ(n.id)+"'><span class='t'>"+esc(n.text)+custTag(n)+" <span class='muted' style='font-size:11px'>"+agoTxt(n.ts)+"</span></span><button class='ghost mkdraft' title='your agent drafts a reply in your voice and leaves it in the comment box, never posted'>Draft a reply</button></div>";}).join('');
1996
+ r.querySelectorAll('.mkdraft').forEach(function(b){b.addEventListener('click',function(){var id=b.closest('.task').getAttribute('data-id');b.disabled=true;
1997
+ api('/api/watch/draft',{id:id}).then(function(x){b.disabled=false;mkMsg(x&&!x.error?'ok':'err',x&&!x.error?DRAFTING_SOCIAL:esc((x&&x.error)||'failed'));}).catch(function(e){b.disabled=false;mkMsg('err',esc(String((e&&e.message)||e)));});});});
1998
+ var T4=d.test;if(!T4){t.innerHTML='';return;}
1999
+ var pk=(T4.packages||[]).join(', '),now=T4.week,over=T4.rows.every(function(x){return x.over;});
2000
+ var cell=function(x,v,extra){return x.started?"<td class='n'>"+v+(extra?" <span class='muted'>("+extra+")</span>":"")+"</td>":"<td class='n muted'>&ndash;</td>";};
2001
+ var h="<div class='pshead'><span>The 4-week test</span><small>"+(over?"Done: "+MKDAY(T4.start)+" to "+MKDAY(T4.end)+".":now?"Week "+now+" of 4 ("+MKDAY(T4.start)+" to "+MKDAY(T4.end)+").":"Starts "+MKDAY(T4.start)+", runs to "+MKDAY(T4.end)+".")+" Does posting bring anyone? Real replies, installs or pricing questions say it does.</small></div>"
2002
+ +"<div class='mkwrap'><table class='mktab'><thead><tr><th>Week</th><th>Posts published</th><th>Replies</th><th title='LinkedIn&#39;s own count: Who viewed your profile'>Profile visits</th><th"+(pk?" title='npm downloads of "+escQ(pk)+"'":"")+">Installs</th><th>Pricing questions</th></tr></thead><tbody>"
2003
+ +T4.rows.map(function(x){return "<tr"+(x.started?"":" class='later'")+(x.started&&!x.over?" aria-current='true'":"")+"><td><b>"+x.week+"</b> <span class='muted'>"+MKDAY(x.from)+"&ndash;"+MKDAY(x.to)+"</span></td>"
2004
+ +cell(x,x.published,x.media?x.media+" with a picture":"")+cell(x,x.replies,x.customers?x.customers+" maybe a customer":"")
2005
+ +(x.started?"<td class='n muted' title='LinkedIn&#39;s own count: its analytics on each post, and Who viewed your profile'>on LinkedIn</td>":"<td class='n muted'>&ndash;</td>")
2006
+ +cell(x,x.installs==null?"&ndash;":Number(x.installs).toLocaleString())+cell(x,x.pricing)+"</tr>";}).join('')+"</tbody></table></div>"
2007
+ +"<div class='muted' style='font-size:12px;margin-top:6px'>"+(pk?"Installs: npm downloads of "+esc(pk)+". Releases, mirrors and CI download too, so trust replies and pricing questions more. ":"")+"Replies and pricing questions come from LinkedIn&#39;s notifications, which Symbiot watches once LinkedIn is linked.</div>";
2008
+ t.innerHTML=h;}
1947
2009
  // ---- Dashboard: a card per page you watch (your inbox, GitHub, WhatsApp…), what's new on each ----
1948
2010
  var BOARD=null;
1949
2011
  // a chat's line says who its last message is from (watch.mjs fromOf): only unread ones count as waiting on you
@@ -1953,7 +2015,7 @@ var BTALK={},BTALKDRAFT={},BTALKBUSY=0;
1953
2015
  var TALKHINT="<div class='muted' style='font-size:12px'>Go over what's new here with your AI: what needs you, and what to say to whom. Then click <b>Draft a reply</b> on one, and your agent writes it the way you agreed here. Your AI is sent what this card lists (for mail: the sender, subject and preview).</div>";
1954
2016
  function talking(){var f=document.activeElement;return BTALKBUSY>0||!!(f&&f.closest&&f.closest('.btalk'));}
1955
2017
  var BOARDH=24;
1956
- function loadBoard(){api('/api/watch/board?hours='+BOARDH).then(function(b){if(b&&b.cards){BOARD=b;if(!talking())renderBoard();}});loadAwaiting();loadPostsUI();}
2018
+ function loadBoard(){api('/api/watch/board?hours='+BOARDH).then(function(b){if(b&&b.cards){BOARD=b;if(!talking())renderBoard();}});loadAwaiting();}
1957
2019
  function boardMsg(cls,html){$('boardmsg').innerHTML="<div class='note "+cls+"'>"+html+"</div>";}
1958
2020
 
1959
2021
  // Replies an agent's email waits on (handback.mjs): Symbiot watches your inbox for them, and hands each on when it's in
@@ -2176,7 +2238,6 @@ document.getElementById('addtrusted').addEventListener('click',addTrustedUI);
2176
2238
  document.getElementById('newtrusted').addEventListener('keydown',function(e){if(e.key==='Enter')addTrustedUI();});
2177
2239
  document.getElementById('driftrun').addEventListener('click',function(){driftLoaded=false;loadDrift();});
2178
2240
  document.getElementById('agentsrefresh').addEventListener('click',loadAgents);
2179
- document.getElementById('addtask').addEventListener('click',addTaskUI);
2180
2241
  document.getElementById('pushtasks').addEventListener('click',pushTasksUI);
2181
2242
  document.getElementById('agentcmd').addEventListener('change',saveAgent);
2182
2243
  document.getElementById('granttoolbtn').addEventListener('click',function(){grant('tool');});
@@ -2188,7 +2249,6 @@ document.getElementById('mailaddrs').addEventListener('change',saveMailAddrs);
2188
2249
  document.getElementById('addmail').addEventListener('click',addMailUI);
2189
2250
  document.getElementById('newmail').addEventListener('keydown',function(e){if(e.key==='Enter')addMailUI();});
2190
2251
  document.getElementById('mailpreview').addEventListener('click',previewMail);
2191
- document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
2192
2252
  document.getElementById('capture').addEventListener('click',captureUI);
2193
2253
  document.getElementById('screenload').addEventListener('click',pickImageUI);
2194
2254
  document.getElementById('mappage').addEventListener('click',function(){mapUI($('pagesite').value,$('mappage'));});
@@ -2336,7 +2396,7 @@ list.forEach(function(d){d.ax=d.tx;d.ay=d.ty;});lqRelax(list,S,cx,cy,92*S.s);
2336
2396
  var old={};LQ.drops.forEach(function(d){old[d.id]=d;});
2337
2397
  LQ.drops=list.map(function(d){var o=old[d.id];d.x=o?o.x:cx;d.y=o?o.y:cy;d.vx=o?o.vx:0;d.vy=o?o.vy:0;d.cr=o?o.cr:0;return d;});
2338
2398
  var n=w.projectCount||list.length,nr=(w.running||[]).length,nw=w.waitingCount||0;
2339
- LQ.coreText=n?n+' project'+(n>1?'s':'')+(nr?' \u00b7 '+nr+' at work':'')+(nw?' \u00b7 '+nw+' tasks waiting':''):'No projects with work on them yet. Add a task, or tell me what to do.';
2399
+ LQ.coreText=n?n+' project'+(n>1?'s':'')+(nr?' \u00b7 '+nr+' at work':'')+(nw?' \u00b7 '+nw+' tasks waiting':''):'No projects with work on them yet. Tell me what to do.';
2340
2400
  var go=$('lqgo');if(go){var k=w.canGo||0;go.textContent=k?'Go \u00b7 start '+k+' task'+(k>1?'s':''):'';if(go.classList)go.classList.toggle('on',k>0);}
2341
2401
  lqLabels();}
2342
2402
  function lqGoWork(){var go=$('lqgo');if(go)go.disabled=true;api('/api/work/go',{}).then(function(r){if(go)go.disabled=false;
@@ -2352,7 +2412,7 @@ LQ.talk.push({me:false,text:t});LQ.talk=LQ.talk.slice(-8);lqTalkShow(false);LQ.r
2352
2412
  // comes out of the core (mass is conserved). Your colours come from the system,
2353
2413
  // live: light or dark, contrast, transparency, forced colours, your accent, night.
2354
2414
  var LQ={drops:[],btns:[],core:{x:0,y:0,cr:0},mode:'aware',last:'',lastAct:Date.now(),talk:[],talking:false,theme:{},ripple:[0.5,0.5,-10],t:0,pointer:null,touch:false,frame:0};
2355
- var LQNAMES={board:'Dashboard',map:'Map',tasks:'Tasks',agents:'Agents',week:'Week',standup:'Standup',todo:'Todo',drift:'Drift',settings:'Settings',reports:'Reports'};
2415
+ var LQNAMES={board:'Dashboard',map:'Map',tasks:'Tasks',agents:'Agents',week:'Week',standup:'Standup',todo:'Todo',drift:'Drift',settings:'Settings',reports:'Reports',marketing:'Marketing'};
2356
2416
  var LQ_REST=60000,LQ_MAX=11;
2357
2417
  function lqMM(q){try{return !!(window.matchMedia&&window.matchMedia(q).matches);}catch(e){return false;}}
2358
2418
  function lqLookGet(){var l='';try{l=window.localStorage&&window.localStorage.getItem('symbiot-look')||'';}catch(e){}return l==='glass'||l==='pearl'?l:'ferro';}
@@ -2374,7 +2434,7 @@ function lqLoad(commit){if(LQ.scene==='work'){lqLoadWork();return;}var fresh=LQ.
2374
2434
  // (work with an end) up left, Areas (what you keep up with) up right, Resources (to
2375
2435
  // look things up in) down left, the Archive (what's done) sunk low; and what's new on
2376
2436
  // what you watch (Watching) as a zone of its own on the right.
2377
- var PARA_OF={tasks:'p',agents:'p',todo:'p',board:'a',week:'a',standup:'a',map:'r',drift:'r',settings:'r',reports:'r'};
2437
+ var PARA_OF={tasks:'p',agents:'p',todo:'p',board:'a',week:'a',standup:'a',marketing:'a',map:'r',drift:'r',settings:'r',reports:'r'};
2378
2438
  var PARA_NAME={p:'Projects',a:'Areas',w:'Watching',r:'Resources',x:'Archive'},PARA_SUB={p:'work with an end',a:'what you keep up with',w:'new on what you watch',r:'to look things up in',x:'what’s done'};
2379
2439
  var PARA_ANG={p:-2.55,a:-0.6,w:0.3,r:2.6,x:1.25},PARA_LIVE={p:0.45,a:0.3,w:0.3,r:0.12,x:0};
2380
2440
  function lqPara(d){return d.kind==='you'?'n':d.kind==='archive'?'x':d.kind==='feed'&&d.shape!=='reports'?'w':PARA_OF[d.shape]||'p';}
@@ -2419,7 +2479,9 @@ else if((d.kind==='you'||d.kind==='feed')&&at[d.shape]!=null)links.push([at[d.sh
2419
2479
  function lqBuild(){var a=LQ.adapt||{},h=LQ.home||{},S=lqSize(),list=[];LQ.bw=S.w;LQ.bh=S.h;
2420
2480
  // the band first: everything else sits under it, round a core centred in what's left
2421
2481
  var need=lqNeeds(h,S),top=need.bottom,cx=S.w/2,cy=need.items.length?Math.max(S.h*0.47,(top+60+S.h-200)/2):S.h*0.47;LQ.cy=cy;LQ.top=top;LQ.need=need;
2422
- var feeds=(h.feeds||[]).slice(0,2),lay=(a.layout&&a.layout.items)||[],more=((a.layout&&a.layout.more)||[]).slice();
2482
+ // Marketing only once posts can be drafted, or are (home.mjs): a new user meets what they watch first
2483
+ var mk=function(id){return id!=='marketing'||!!h.marketing;};
2484
+ var feeds=(h.feeds||[]).slice(0,2),lay=((a.layout&&a.layout.items)||[]).filter(function(it){return mk(it.id);}),more=((a.layout&&a.layout.more)||[]).filter(mk);
2423
2485
  var vx=Math.max(0.5,Math.min(S.s*1.3,(S.w/2-90)/400)),vy=Math.max(0.35,Math.min(S.s*0.8,(S.h-cy-230)/400,(cy-(top?top+60:160))/400));var rs=Math.min(S.s,(vx+vy)/1.5);var room=Math.max(3,LQ_MAX-feeds.length-1-(need.items.length?2:0));lay.slice(room).forEach(function(it){more.push(it.id);});
2424
2486
  var repNew=(h.feeds||[]).filter(function(f){return f.id==='feed:reports';})[0],repOut=repNew&&feeds.indexOf(repNew)<0; // new reports, when their own droplet didn't fit
2425
2487
  lay.slice(0,room).forEach(function(it){list.push({id:'shape:'+it.id,kind:'shape',shape:it.id,title:LQNAMES[it.id]||it.id,sub:it.id==='reports'&&repOut?repNew.count+' new':'',r:it.r*rs,tx:cx+Math.cos(it.angle)*it.d*vx,ty:cy+Math.sin(it.angle)*it.d*vy});});
@@ -2447,12 +2509,14 @@ LQ.talkWeight=(a.modes&&a.modes.talkWeight)||0.35;
2447
2509
  lqLabels();}
2448
2510
  // Home in zones, on a screen wide enough for them: the core in the middle of what's
2449
2511
  // under the band; Projects then Resources to its left, Areas then Watching (with the
2450
- // Archive and "more" at its end) to its right; each a row with its name at its
2451
- // start. Fixed places in a fixed order, so nothing overlaps and each part is where
2452
- // it was last time. Sizes still come from how you use them (adapt.mjs), all scaled
2512
+ // Archive and "more" at its end) to its right; each a row with its name at its outer
2513
+ // end, so the droplets, which flow out of the core, never cross a name on their way
2514
+ // (on the right, names next to the core had the Dashboard orb sitting on "what you
2515
+ // keep up with" until it landed). Fixed places in a fixed order, so nothing overlaps
2516
+ // and each part is where it was last time. Sizes still come from how you use them (adapt.mjs), all scaled
2453
2517
  // by one factor when the screen is short, so they stay comparable. null when they
2454
2518
  // can't fit (a narrow or very short screen): the liquid's own layout then.
2455
- var ZONE_ORDER=['tasks','agents','todo','board','week','standup','map','drift','settings','reports'],ZONE_KIND={shape:0,feed:1,archive:2,more:3};
2519
+ var ZONE_ORDER=['tasks','agents','todo','board','week','standup','marketing','map','drift','settings','reports'],ZONE_KIND={shape:0,feed:1,archive:2,more:3};
2456
2520
  function lqZones(list,S,top,coreR){LQ.zoneT=null;if(S.w<1000||S.h<540)return null;
2457
2521
  var y0=top?top+10:96,y1=S.h-180,mid=(y0+y1)/2,cx=S.w/2,gapC=coreR+36,colW=S.w/2-gapC-20,titleW=150;if(colW-titleW<250||mid-y0<70)return null;
2458
2522
  var cells={p:[cx-gapC-colW,y0,mid],a:[cx+gapC,y0,mid],r:[cx-gapC-colW,mid,y1],w:[cx+gapC,mid,y1]},by={},plan={},k=1;
@@ -2466,8 +2530,8 @@ var R=0;m.forEach(function(d){R=Math.max(R,d.r);});var av=colW-titleW,fitW=funct
2466
2530
  if(fitW(per)<0.6&&m.length>1)per=Math.ceil(m.length/2);var rows=Math.ceil(m.length/per);
2467
2531
  k=Math.min(k,fitW(per),((c[2]-c[1])/rows-46)/(2*R));plan[g]={m:m,per:per,rows:rows,stepX:av/per};});
2468
2532
  if(k<0.55)return null;k=Math.min(1,k);LQ.zoneT={};
2469
- Object.keys(plan).forEach(function(g){var c=cells[g],p=plan[g],rowH=(c[2]-c[1])/p.rows,x0=c[0]+titleW+(colW-titleW)/2;
2470
- LQ.zoneT[g]=[c[0]+titleW/2,c[1]+rowH/2-13];
2533
+ Object.keys(plan).forEach(function(g){var c=cells[g],p=plan[g],rowH=(c[2]-c[1])/p.rows,right=g==='a'||g==='w',x0=c[0]+(right?0:titleW)+(colW-titleW)/2;
2534
+ LQ.zoneT[g]=[right?c[0]+colW-titleW/2:c[0]+titleW/2,c[1]+rowH/2-13];
2471
2535
  p.m.forEach(function(d,i){d.r*=k;var row=Math.floor(i/p.per),n=Math.min(p.per,p.m.length-row*p.per);d.tx=x0+(i%p.per-(n-1)/2)*p.stepX;d.ty=c[1]+row*rowH+rowH/2-13;});});
2472
2536
  return {cy:mid};}
2473
2537
  // Force-directed layout, solved once per change: push overlapping targets apart
@@ -2543,7 +2607,7 @@ function lqGo(shape){if(shape==='tasks'||shape==='agents'){lqWorkBy('task');retu
2543
2607
  function blobOpt(o){return String(o||'').replace(/[ ]*[(]recommended[)][ ]*$/i,'');}
2544
2608
  function blobBody(y,home){var h='',nm=y.name||y.repo||(y.kind==='setup'?y.title:'')||'';
2545
2609
  h+=home?"<button type='button' class='lt' title='open "+escQ(nm)+", with this lit up'><span>"+esc(lqShort(nm,30))+"</span><i>open &rsaquo;</i></button>":"<div class='nhead'><b>"+esc(nm)+(y.kind==='ask'?" asks":"")+"</b>"+(y.more?"<span class='muted'>+"+y.more+" more on its block in Agents</span>":"")+"</div>";
2546
- if(y.kind!=='ask')return h+"<div class='bq'>"+esc(y.kind==='approve'?String(y.sub||'').replace(/ · only you decide$/,''):String(y.sub||'').charAt(0).toUpperCase()+String(y.sub||'').slice(1)+'.')+"</div><div class='bo'><button type='button' class='nopt rec ngo'><span class='bt'>"+(y.kind==='approve'?'Review and approve':y.id==='setup:inbox'?(y.signin?'Sign in again':'Show me your inbox'):y.id==='setup:ai'?'Connect an AI':y.urgent?'Reconnect':'Open Settings')+"</span></button></div>";
2610
+ if(y.kind!=='ask')return h+"<div class='bq'>"+esc(y.kind==='approve'?String(y.sub||'').replace(/ · only you decide$/,''):String(y.sub||'').charAt(0).toUpperCase()+String(y.sub||'').slice(1)+'.')+"</div><div class='bo'><button type='button' class='nopt rec ngo'><span class='bt'>"+(y.kind==='approve'?'Review and approve':y.id==='setup:inbox'?(y.signin?'Sign in again':'Show me your inbox'):y.id==='setup:ai'?'Connect an AI':y.pick?'Use '+y.pick.name:y.urgent?'Reconnect':'Open Settings')+"</span></button></div>";
2547
2611
  var os=y.options||[];h+="<div class='bq' title='"+escQ((y.q||'')+(y.why?' ('+y.why+')':''))+"'>"+esc(y.q||y.sub||'')+"</div>";
2548
2612
  if(y.fix==='handover'&&y.sub)h+="<div class='bfor' title='"+escQ(y.sub)+"'>for: "+esc(y.sub)+"</div>";
2549
2613
  if(os.length)h+="<div class='bo'>"+os.map(function(o,j){return "<button type='button' class='nopt"+(j===0&&/[(]recommended[)]/i.test(o)?' rec':'')+"' data-j='"+j+"' title='"+escQ(o)+"'><span class='bt'>"+whoHtml(blobOpt(o))+"</span></button>";}).join('')+"</div><button type='button' class='bfree' aria-expanded='false'>or answer in your own words</button>";
@@ -2563,7 +2627,10 @@ box.querySelectorAll('.nopt[data-j]').forEach(function(b){b.addEventListener('cl
2563
2627
  var fr=box.querySelector('.bfree'),fx=box.querySelector('.bfx');
2564
2628
  if(fr&&fx)fr.addEventListener('click',function(){var shut=fx.classList.toggle('hidden');fr.setAttribute('aria-expanded',shut?'false':'true');var i=fx.querySelector('input');if(!shut&&i&&i.focus)i.focus();});
2565
2629
  if(fx)fx.addEventListener('submit',function(ev){if(ev&&ev.preventDefault)ev.preventDefault();var i=fx.querySelector('input');send(i&&i.value);});
2566
- var go=box.querySelector('.ngo');if(go)go.addEventListener('click',function(){lqFocus(y);});}
2630
+ var go=box.querySelector('.ngo');if(go)go.addEventListener('click',function(){if(!y.pick){lqFocus(y);return;}
2631
+ // Pick your agent: the one Symbiot found, in one click; Settings has the others
2632
+ lock(true);api('/api/agentcmd',{cmd:y.pick.tmpl}).then(function(r){if(!r||r.error){lock(false);say((r&&r.error)||'It didn’t save. Pick it in Settings.',true);return;}
2633
+ say('Your agent is '+y.pick.name+' now: Send to repos and Go hand your tasks to it.');if(box.classList)box.classList.add('gone');LQ.fresh=true;setTimeout(function(){if(o.done)o.done();else lqLoad(false);},650);}).catch(function(e){lock(false);say(String((e&&e.message)||e),true);});});}
2567
2634
  function lqSaid(t){LQ.talk.push({me:false,text:t});LQ.talk=LQ.talk.slice(-8);lqTalkShow(false);}
2568
2635
  // A lit orb opens its project with what lit it lit up and in view (lqLight): a
2569
2636
  // repo's questions and its Approve, under "Needs you here" at the top of its
@@ -2644,7 +2711,10 @@ if(still){d.x=tx;d.y=ty;d.vx=0;d.vy=0;}else{d.vx+=ax;d.vy+=ay;d.x+=d.vx;d.y+=d.v
2644
2711
  if(!rest&&!pool){d.x=Math.max(d.cr+12,Math.min(S.w-d.cr-12,d.x));d.y=Math.max((d.blob?60:140)+d.cr,Math.min(S.h-150-d.cr-50,d.y));}
2645
2712
  d.cr+=(tr-d.cr)*(still?1:0.06);
2646
2713
  var b=LQ.btns[i];if(b&&b.offsetWidth)d.lw=b.offsetWidth;if(b&&b.style){b.style.transform=d.blob?'translate('+Math.round(d.x+d.r+30)+'px,'+Math.round(d.y-d.r-4)+'px)':'translate('+Math.round(d.x-(b.offsetWidth||0)/2)+'px,'+Math.round(d.y+d.cr+(d.kind==='run'?28:4))+'px)';var show=!rest&&!pool;b.style.opacity=show?'1':'0';b.style.pointerEvents=show?'auto':'none';var lt=LQ.lts&&LQ.lts[i];if(lt)lt.tabIndex=show?0:-1;}}
2647
- (LQ.gbtns||[]).forEach(function(gb){var g=gb.getAttribute('data-g'),top=1e9,sx=0,nn=0;D.forEach(function(d){if(d.kind!=='more'&&lqPara(d)===g){top=Math.min(top,d.y-d.cr);sx+=d.x;nn++;}});if(!nn||!gb.style)return;var gw=gb.offsetWidth||0,gh=gb.offsetHeight||0,zt=LQ.scene!=='work'&&LQ.zoneT&&LQ.zoneT[g];if(zt)gb.style.transform='translate('+Math.round(zt[0]-gw/2)+'px,'+Math.round(zt[1]-gh/2)+'px)';else gb.style.transform='translate('+Math.round(Math.max(12,Math.min(lqSize().w-gw-12,sx/nn-gw/2)))+'px,'+Math.round(Math.max(LQ.scene!=='work'&&LQ.top?LQ.top:48,top-gh-30))+'px)';var gshow=!rest&&!pool&&!talk;gb.style.opacity=gshow?'1':'0';gb.style.pointerEvents=gshow?'auto':'none';gb.tabIndex=gshow?0:-1;});
2714
+ (LQ.gbtns||[]).forEach(function(gb){var g=gb.getAttribute('data-g'),top=1e9,sx=0,nn=0;D.forEach(function(d){if(d.kind!=='more'&&lqPara(d)===g){top=Math.min(top,d.y-d.cr);sx+=d.x;nn++;}});if(!nn||!gb.style)return;var gw=gb.offsetWidth||0,gh=gb.offsetHeight||0,zt=LQ.scene!=='work'&&LQ.zoneT&&LQ.zoneT[g];if(zt)gb.style.transform='translate('+Math.round(zt[0]-gw/2)+'px,'+Math.round(zt[1]-gh/2)+'px)';else{var gx=Math.max(12,Math.min(lqSize().w-gw-12,sx/nn-gw/2)),gy=top-gh-30;
2715
+ // off any droplet it would sit on (the "more" droplet, another group's): above it instead
2716
+ for(var pass=0;pass<3;pass++)D.forEach(function(d){if(d.blob||lqPara(d)===g&&d.kind!=='more')return;var l=d.x-d.cr-6,r=d.x+d.cr+6,t=d.y-d.cr-6,b=d.y+d.cr+(d.lw?24:6);if(gx<r&&gx+gw>l&&gy<b&&gy+gh>t)gy=t-gh-4;});
2717
+ gb.style.transform='translate('+Math.round(gx)+'px,'+Math.round(Math.max(LQ.scene!=='work'&&LQ.top?LQ.top:48,gy))+'px)';}var gshow=!rest&&!pool&&!talk;gb.style.opacity=gshow?'1':'0';gb.style.pointerEvents=gshow?'auto':'none';gb.tabIndex=gshow?0:-1;});
2648
2718
  var ntl=$('lqneedt');if(ntl&&ntl.style)ntl.style.opacity=rest?'0':'1';
2649
2719
  (LQ.orbs||[]).forEach(function(o){var d=D[+o.getAttribute('data-i')];if(!d||!o.style)return;var R=Math.round(d.cr+20);o.style.width=o.style.height=(2*R)+'px';o.style.transform='translate('+Math.round(d.x-R)+'px,'+Math.round(d.y-R)+'px)';o.style.opacity=rest||pool?'0':'1';});
2650
2720
  var cb=$('lqcore');if(cb&&cb.style){var inC=LQ.theme&&LQ.theme.look!=='pearl'&&core.cr>60;cb.style.maxWidth=inC?Math.round(core.cr*1.5)+'px':'340px';var cw=cb.offsetWidth||0,chh=cb.offsetHeight||0;cb.style.transform='translate('+Math.round(cx-cw/2)+'px,'+(rest||inC?(inC?Math.round(core.y-chh/2):Math.round(core.y+core.cr+14)):LQ.scene==='work'?112:56)+'px)';var ct=rest?(LQ.coreText||'All handled'):talk||pool?'':(LQ.coreText||'');lqCoreText(cb,ct);cb.style.opacity=ct?'1':'0';cb.style.pointerEvents=ct?'auto':'none';}}