symbiot 0.45.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
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.47.0
6
+
7
+ - **Tasks and agents in the liquid.** Opening Tasks or Agents re-forms the liquid: each agent at work is a sphere with small spheres revolving round it, named for what it's doing in plain words; work ready for your OK sits at the top; tasks waiting their turn sit below. One **Go** (or just say "go") starts everything waiting.
8
+ - **Tags float on their spheres.** Each sphere carries just its name, floating with a shadow. Tap the sphere to open its tag for a line about it and **Open** for the details.
9
+ - **Less to read.** An agent's block shows what it's doing and its to-do list; steps, files, tokens and cost are under "details".
10
+ - **No stray box.** The moving silver edge round open panels is gone.
11
+
12
+ ## 0.46.0 — 2026-10-06
13
+
14
+ - **The Symbiot look on every part.** Whatever you open pools over the liquid as glass, with a slow silver edge: cards are glass, the main buttons chrome, the map's nodes small silver spheres.
15
+ - **Watch an agent work, as it works.** What it's doing right now, its own to-do list ticking off (with a progress ring), each step in plain words with how long it took, test results, the files it changed, its pace, its model, turns, tokens and cost, and finally its answer.
16
+ - **Update prompts show again.** Since 0.45.0 the "Update & restart" bar was hidden behind the liquid; it now sits on top, as a chrome bar at the top of the window.
17
+ - **Calm, readable droplets.** Droplets keep clear of each other, past where their metal would merge, so none of them flicker or shake their labels; when a screen can't hold them all, the least used go under "more" instead of crowding.
18
+ - **See Symbiot think.** Under each chat reply: what it did to answer (what it recalled, the links it looked up, that it matched how you talk, what it handed over or remembered).
19
+
5
20
  ## 0.45.0 — 2026-10-06
6
21
 
7
22
  - **The liquid.** Symbiot's home is now one surface of liquid silver that shapes itself to you, with no settings. Its droplets are what only you can do, what's new on what you watch, and the parts of the app, which pool open over it. Talk to it in the bar at the bottom; leave it alone and it rests as one orb.
package/agents.mjs CHANGED
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync, readdirSync, statSync } from "node:fs";
9
9
  import { randomBytes, createHash } from "node:crypto";
10
10
  import { CONFIG_DIR, loadConfig, saveConfig, loadTasks, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
11
+ import { parseRun, lastRunText } from "./work.mjs";
11
12
 
12
13
  const HANDOFFS = []; // live registry of agents Symbiot has handed work to
13
14
  // ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
@@ -138,7 +139,7 @@ function runHandoff(repoPath, { force = false } = {}) {
138
139
  let opts = {}; try { opts = JSON.parse(readSymbiot(repoPath, "handoff.json")) || {}; } catch {}
139
140
  const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid, auto: !!busy.auto };
140
141
  releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
141
- tmpl = withConnectors(tmpl, repoPath);
142
+ tmpl = withStream(withConnectors(tmpl, repoPath));
142
143
  if (!force) { const w = waitingFor(repoPath); if (w) { askedFor(repoPath); return { blocked: true, waiting: true, questions: 0, note: waitNote(w) }; } }
143
144
  const blocked = !force && blockedAgain(repoPath, tmpl); if (blocked) return blocked;
144
145
  try { unlinkSync(join(repoPath, ".symbiot", WAITING)); } catch {} // the step's done (or this is Start it anyway): this is the run it waited for
@@ -180,8 +181,8 @@ function earlierRuns() {
180
181
  const busy = runningHandoff(r.path), ask = agentQuestions(r.path, r.name), wait = waitingFor(r.path);
181
182
  if (!busy && !ask.questions.length && !wait) continue;
182
183
  const log = join(r.path, ".symbiot", "agent.log"), startedAt = (busy && busy.startedAt) || Number(r.startedAt) || Date.now();
183
- let tail = "", end = Date.now(); try { tail = readFileSync(log, "utf8").slice(-1200); if (!busy) end = statSync(log).mtimeMs; } catch {}
184
- 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, changed: agentChanges(r.path, startedAt), ask, held: heldTasks(r.path), waiting: wait, fromHeld: false });
184
+ let tail = "", work = null, progress = null, end = Date.now(); try { ({ tail, work, progress } = workOf(r.path, readFileSync(log, "utf8"))); if (!busy) end = statSync(log).mtimeMs; } catch {}
185
+ 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, fromHeld: false });
185
186
  }
186
187
  return out;
187
188
  }
@@ -614,13 +615,32 @@ function heldTasks(path) {
614
615
  // newest job per folder — the questions/ideas it left, any tasks held for it and
615
616
  // a step of yours it waits on. Then runs from before the app started that still
616
617
  // need you (earlierRuns).
618
+ // A run's work, to draw: parsed from its log (work.mjs), with how far through its
619
+ // TASKS.md it is. tail is what to show as text: what it said, for a streaming
620
+ // run; the log's end, for another agent.
621
+ // Claude Code in print mode (claude -p) streams each step as JSON, so the work
622
+ // can be drawn as it happens (work.mjs). Added to a claude -p command that has no
623
+ // output format of its own; anything else (an Orca tab, another agent) as it is.
624
+ function withStream(cmd) {
625
+ const c = String(cmd || "");
626
+ if (!/^\s*claude\b/.test(c) || !/\s-p\b|\s--print\b/.test(c) || /--output-format\b/.test(c)) return c;
627
+ return c + " --output-format stream-json --verbose";
628
+ }
629
+ function workOf(path, log) {
630
+ const w = parseRun(lastRunText(log)), md = readSymbiot(path, "TASKS.md");
631
+ const done = (md.match(/^\s*-\s*\[x\]/gim) || []).length, total = done + (md.match(/^\s*-\s*\[ \]/gm) || []).length;
632
+ const tail = w.stream ? (w.final || w.said.join("\n\n") || (w.doing ? w.doing + "…" : "")) : String(log).slice(-1200);
633
+ const work = w.stream ? { model: w.model, doing: w.doing, steps: w.steps.slice(-16), todos: w.todos, cost: w.cost, turns: w.turns, tokens: w.tokens, tests: w.tests, files: w.files, pace: w.pace, errors: w.errors, final: w.final.slice(0, 1200), count: w.steps.length } : null;
634
+ return { tail, work, progress: total ? { done, total } : null };
635
+ }
617
636
  function agentsList() {
618
637
  const seen = new Set();
619
638
  return HANDOFFS.map((e) => {
620
- let tail = ""; try { tail = readFileSync(e.log, "utf8").slice(-1200); } catch {}
639
+ let log = ""; try { log = readFileSync(e.log, "utf8"); } catch {}
640
+ const { tail, work, progress } = workOf(e.path, log);
621
641
  const first = !seen.has(e.path); seen.add(e.path);
622
- return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, 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, fromHeld: !!e.fromHeld };
642
+ return { id: e.id, name: e.name, path: e.path, status: e.status, 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, fromHeld: !!e.fromHeld };
623
643
  }).concat(earlierRuns());
624
644
  }
625
645
 
626
- export { 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, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList };
646
+ export { 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, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList };
package/home.mjs CHANGED
@@ -7,8 +7,9 @@
7
7
  // how you use them; this is the live data around them. Home's talk goes to the
8
8
  // same Symbiot as every chat (mind.mjs converse), told what home shows.
9
9
  import { watchBoard } from "./watch.mjs";
10
- import { pendingReview } from "./tasks.mjs";
11
- import { agentsList } from "./agents.mjs";
10
+ import { pendingReview, pushTasks } from "./tasks.mjs";
11
+ import { agentsList, runHandoff } from "./agents.mjs";
12
+ import { loadTasks } from "./core.mjs";
12
13
  import { lanesState } from "./lanes.mjs";
13
14
  import { converse, actIn, actNow, taskIn } from "./mind.mjs";
14
15
  import { repoPathMap } from "./scan.mjs";
@@ -68,7 +69,39 @@ async function homeAsk(question, { ask, now = Date.now(), state } = {}) {
68
69
  agent: (req, known, repo) => (repo ? actIn(req, repo, { map, known, title: "Home" }) : actNow(req, { title: "Home", known })),
69
70
  task: (text, repo) => taskIn(text, repo, { map }),
70
71
  } });
71
- return { answer: r.reply, ...(r.did ? { did: r.did } : {}) };
72
+ return { answer: r.reply, ...(r.did ? { did: r.did } : {}), steps: r.steps || [] };
72
73
  }
73
74
 
74
- export { homeState, homeContext, homeAsk };
75
+ // ---- the work scene: the liquid when you open Tasks or Agents ---------------------
76
+ // Plain words for someone who doesn't read diffs: what each agent is doing now,
77
+ // what's waiting its turn, what's done and waiting for your OK, and whether
78
+ // there's anything a Go would start. The details (steps, files, cost) stay one
79
+ // tap away, in the Agents panel.
80
+ const plain = (s, n = 64) => { s = String(s || "").replace(/[\`*_#>]/g, "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n - 1).replace(/\s+\S*$/, "") + "…" : s; };
81
+ function workScene({ deps = {} } = {}) {
82
+ const agents = deps.agents || (() => { try { return agentsList(); } catch { return []; } });
83
+ const pending = deps.pending || (() => { try { return pendingReview(); } catch { return []; } });
84
+ const tasks = deps.tasks || (() => { try { return loadTasks(); } catch { return []; } });
85
+ const seen = new Set(), running = [];
86
+ for (const a of agents()) {
87
+ if (a.status !== "running" || seen.has(a.path)) continue; seen.add(a.path);
88
+ const w = a.work || {}, todo = (w.todos || []).find((t) => t.status === "in_progress"), pg = a.progress;
89
+ running.push({ id: "run:" + a.path, path: a.path, name: a.name, doing: plain(todo ? todo.active : w.doing || "Working on it", 56), progress: pg || null, waiting: !!(a.ask && a.ask.questions && a.ask.questions.length) });
90
+ }
91
+ const ready = pending().filter((r) => r.path && !r.running && ((r.tasks || []).length || (r.files || []).length)).map((r) => ({ id: "ready:" + r.repo, repo: r.repo, count: (r.tasks || []).length }));
92
+ const busy = new Set(running.map((r) => r.name)), open = tasks().filter((t) => !t.done && !t.archived && !t.review && t.repo);
93
+ const waiting = open.map((t) => ({ id: "task:" + t.id, text: plain(t.text, 56), repo: t.repo, busy: busy.has(t.repo) }));
94
+ return { running, ready, waiting: waiting.slice(0, 12), waitingCount: waiting.length, canGo: waiting.filter((w) => !w.busy).length };
95
+ }
96
+ // Go: everything waiting goes to its repo's agent, the way Send to repos and an
97
+ // agent per repo would: briefs written, an agent started in each (or queued
98
+ // behind one already there). { started, queued, repos } or { error }.
99
+ function workGo({ push = pushTasks, run = runHandoff } = {}) {
100
+ const r = push({});
101
+ if (r.empty) return { started: 0, queued: 0, repos: [], note: "Nothing waiting to start." };
102
+ let started = 0, queued = 0; const repos = [];
103
+ for (const w of r.written || []) { const e = run(w.path); if (e && e.id && !e.busy && !e.blocked) started++; else queued++; repos.push(w.name); }
104
+ return { started, queued, repos, ...(r.unresolved && r.unresolved.length ? { unresolved: r.unresolved.map((u) => u.name) } : {}) };
105
+ }
106
+
107
+ export { homeState, homeContext, homeAsk, workScene, workGo };
package/lanes.mjs CHANGED
@@ -23,6 +23,7 @@ import { runHandoff, runningHandoff, waitingFor } from "./agents.mjs";
23
23
  import { addTask, pushTasks } from "./tasks.mjs";
24
24
  import { actNow } from "./mind.mjs";
25
25
  import { repoPathMap } from "./scan.mjs";
26
+ import { parseRun, lastRunText } from "./work.mjs";
26
27
  import { OPS, parseHandoffs } from "./handover.mjs";
27
28
 
28
29
  const LEDGER = join(CONFIG_DIR, "lanes.json");
@@ -83,7 +84,8 @@ function dispatch(path, { map = repoPathMap(), act = actNow, run = runHandoff, a
83
84
  // What an agent said last: its log after the newest run's header.
84
85
  function lastWords(path) {
85
86
  const log = readSym(path, "agent.log"), at = log.lastIndexOf("\n=== "); if (at < 0) return "";
86
- const run = log.slice(at).split("\n").slice(3).join("\n").trim(); // past the header and the command
87
+ const w = parseRun(lastRunText(log)); // a streaming run: its final answer, not its JSON
88
+ const run = w.stream ? (w.final || w.said.join("\n")) : log.slice(at).split("\n").slice(3).join("\n").trim(); // past the header and the command
87
89
  return run.length > TAIL ? "…" + run.slice(-TAIL) : run;
88
90
  }
89
91
  // When the newest run in a folder started (its log header), or 0.
package/mind.mjs CHANGED
@@ -190,7 +190,12 @@ function parseReply(raw) {
190
190
  // own last turns. act: { agent(request) -> result, task(text, repo) -> result },
191
191
  // what "do" runs here. Gives { reply, did?, remembered }.
192
192
  async function converse({ where, role = "", context = "", history = "", question, act = {}, ask = write, map = null, now = Date.now() }) {
193
- const d = loadMind(), known = recallText(recall(question, now, d)), elsewhere = lately(where, d);
193
+ const d = loadMind(), hits = recall(question, now, d), known = recallText(hits), elsewhere = lately(where, d);
194
+ // what it did to answer, to show under the reply (as the app shows an agent's work)
195
+ const steps = [];
196
+ if (hits.length) steps.push(`recalled ${hits.length === 1 ? hits[0].name : hits.length + " things: " + hits.slice(0, 3).map((h) => h.name).join(", ")}`);
197
+ const places = [...new Set(d.log.filter((l) => l.where !== where).slice(-6).map((l) => l.where))];
198
+ if (places.length) steps.push(`picked up what you said on ${places.slice(0, 2).join(" and ")}`);
194
199
  let lanes = map; if (!lanes) { try { lanes = repoPathMap(); } catch { lanes = {}; } }
195
200
  // how they talk, from what they've typed into any chat (adapt.mjs: accommodation)
196
201
  const voice = styleLine(styleOf(d.log.filter((l) => l.role === "user").map((l) => l.text).concat(question)));
@@ -219,7 +224,10 @@ async function converse({ where, role = "", context = "", history = "", question
219
224
  const d2 = loadMind(), remembered = rememberIn(d2, j.remember, where, now);
220
225
  logTurn(where, "user", question, now, d2); logTurn(where, "ai", reply, now, d2);
221
226
  saveMind(d2);
222
- return { reply, ...(did ? { did } : {}), remembered };
227
+ if (voice) steps.push("matched how you talk");
228
+ if (did && !did.error) steps.push(did.kind === "agent" ? (did.lane ? `handed it to ${did.lane}'s agent` : "handed it to an agent") : did.lane ? `added a task to ${did.lane}` : "added a task");
229
+ if (remembered) steps.push(`remembered ${remembered} new thing${remembered > 1 ? "s" : ""}`);
230
+ return { reply, ...(did ? { did } : {}), remembered, steps };
223
231
  }
224
232
  // The usual "task" act, for a caller with no lanes of its own to pass.
225
233
  const addToTasks = (text, repo) => { let map = {}; try { map = repoPathMap(); } catch {} return taskIn(text, repo, { map }); };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.45.0",
3
+ "version": "0.47.0",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,6 +28,7 @@
28
28
  "lanes.mjs",
29
29
  "adapt.mjs",
30
30
  "home.mjs",
31
+ "work.mjs",
31
32
  "phone.mjs",
32
33
  "desktop.mjs",
33
34
  "README.md",
@@ -65,7 +66,7 @@
65
66
  "@anthropic-ai/sdk": "^0.128.0"
66
67
  },
67
68
  "scripts": {
68
- "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/peek.mjs && node test/mind.mjs && node test/lanes.mjs && node test/adapt.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
69
+ "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/peek.mjs && node test/mind.mjs && node test/lanes.mjs && node test/adapt.mjs && node test/work.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
69
70
  "lint": "eslint ."
70
71
  },
71
72
  "devDependencies": {
package/server.mjs CHANGED
@@ -26,7 +26,7 @@ import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
26
26
  import { mindState, forget } from "./mind.mjs";
27
27
  import { lanesTick, lanesState } from "./lanes.mjs";
28
28
  import { adaptState, noteUse } from "./adapt.mjs";
29
- import { homeState, homeAsk } from "./home.mjs";
29
+ import { homeState, homeAsk, workScene, workGo } from "./home.mjs";
30
30
  import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
31
31
 
32
32
  // The in-app update installs the EXACT newest version (not the `latest` tag, which
@@ -280,6 +280,9 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
280
280
  // Home (home.mjs): the liquid's live data, and its talk; Adapt (adapt.mjs): its
281
281
  // shape from how you use it (commit=1 when it wakes from rest, never mid-gesture).
282
282
  if (u.pathname === "/api/home") return json(res, homeState({ fresh: u.searchParams.get("fresh") === "1" }));
283
+ // the work scene: what agents are doing, what's waiting, what's ready; Go starts what's waiting
284
+ if (u.pathname === "/api/work") return json(res, workScene());
285
+ if (u.pathname === "/api/work/go" && req.method === "POST") return json(res, workGo());
283
286
  if (u.pathname === "/api/home/ask" && req.method === "POST") { const b = await readBody(req); return json(res, await homeAsk(b.question)); }
284
287
  if (u.pathname === "/api/adapt") return json(res, adaptState({ from: String(u.searchParams.get("from") || ""), commit: u.searchParams.get("commit") === "1", ...(u.searchParams.has("touch") ? { touch: u.searchParams.get("touch") === "1" } : {}) }));
285
288
  if (u.pathname === "/api/adapt/use" && req.method === "POST") { const b = await readBody(req); return json(res, noteUse(b)); }
package/ui.mjs CHANGED
@@ -19,8 +19,15 @@ body.lq-liquid.lq-light #liquid{background:#EDEFF3}
19
19
  #lq{position:absolute;inset:0;width:100%;height:100%;display:block}
20
20
  body.lq-forced #lq{display:none}
21
21
  #lqdrops{position:absolute;inset:0;pointer-events:none}
22
- .lqd{position:absolute;left:0;top:0;display:flex;flex-direction:column;align-items:center;gap:2px;min-height:44px;padding:8px 14px;border-radius:999px;border:1px solid rgba(220,228,240,.35);background:rgba(4,5,7,.72);backdrop-filter:blur(10px);color:#EEF2F8;font:600 14px/1.15 var(--sans);white-space:nowrap;cursor:pointer;pointer-events:auto;transition:opacity .5s ease;will-change:transform}
23
- .lqd small{font-weight:500;font-size:12px;color:#A9B3C2;max-width:300px;overflow:hidden;text-overflow:ellipsis}
22
+ .lqd{position:absolute;left:0;top:0;display:flex;flex-direction:column;align-items:center;border-radius:999px;border:1px solid rgba(220,228,240,.35);background:rgba(4,5,7,.72);backdrop-filter:blur(10px);color:#EEF2F8;font:600 13.5px/1.2 var(--sans);pointer-events:auto;transition:opacity .5s ease,border-radius .25s ease;will-change:transform;box-shadow:0 16px 30px -12px rgba(0,0,0,.7),0 3px 8px rgba(0,0,0,.35)}
23
+ .lqd .lt{all:unset;box-sizing:border-box;display:flex;align-items:center;justify-content:center;min-height:44px;padding:8px 15px;cursor:pointer;text-align:center;max-width:240px}
24
+ .lqd .lt span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
25
+ .lqd .lgo{display:none;margin:4px 10px 10px;min-height:38px;padding:0 16px;border-radius:999px;border:1px solid #FFFFFF;background:linear-gradient(180deg,#FFFFFF 0%,#D2D8E1 44%,#8E97A6 56%,#E6EAF0 100%);color:#06080B;font:700 12.5px var(--sans);cursor:pointer}
26
+ .lqd.open{z-index:5;border-radius:20px}.lqd.open .lt span{white-space:normal}.lqd.open .lt{max-width:280px}.lqd.open .lgo{display:block}
27
+ .lqd .lt:focus-visible,.lqd .lgo:focus-visible{outline:2px solid currentColor;outline-offset:2px;border-radius:999px}
28
+ .lqmb{min-height:44px;padding:8px 14px;border-radius:999px;border:1px solid rgba(220,228,240,.35);background:rgba(4,5,7,.72);backdrop-filter:blur(10px);color:#EEF2F8;font:600 13.5px var(--sans);cursor:pointer}
29
+ body.lq-light .lqmb{background:rgba(255,255,255,.86);color:#10151C;border-color:rgba(20,30,45,.25)}
30
+ .lqd small{display:none;font-weight:500;font-size:12px;line-height:1.35;color:#A9B3C2;max-width:260px;padding:0 15px 2px;text-align:center}.lqd.open small{display:block}
24
31
  .lqd.lq-you{border-color:rgba(255,255,255,.9)}
25
32
  .lqd:hover{border-color:rgba(255,255,255,.95)}
26
33
  .lqd:focus-visible,#lqcore:focus-visible,#lqask:focus-visible{outline:2px solid currentColor;outline-offset:3px}
@@ -33,7 +40,7 @@ body.lq-light.lq-contrast .lqd{background:#FFF;color:#000;border:2px solid #000}
33
40
  body.lq-light.lq-contrast .lqd small{color:#000}
34
41
  body.lq-solid .lqd,body.lq-solid .lqglass{backdrop-filter:none;background:#05070A}
35
42
  body.lq-light.lq-solid .lqd,body.lq-light.lq-solid .lqglass{background:#FFFFFF}
36
- body.lq-touch .lqd{min-height:48px;padding:11px 16px}
43
+ body.lq-touch .lqd .lt{min-height:48px}
37
44
  body.lq-keys .lqd:focus{outline:2px solid currentColor;outline-offset:3px}
38
45
  #lqcore{position:absolute;left:0;top:0;min-height:44px;padding:0 16px;border:1px solid rgba(220,228,240,.22);border-radius:999px;background:rgba(4,5,7,.6);backdrop-filter:blur(8px);color:#C3CBD7;font:500 13px var(--sans);letter-spacing:.05em;cursor:pointer;transition:opacity .6s ease;white-space:nowrap}
39
46
  body.lq-light #lqcore{color:#2B3644;background:rgba(255,255,255,.78);border-color:rgba(20,30,45,.18)}
@@ -60,6 +67,81 @@ body.lq-liquid footer{position:fixed;right:12px;top:38px;border:0;padding:0;z-in
60
67
  body.lq-pooled #lqtalk,body.lq-pooled #lqmore,body.lq-pooled #lqcore{display:none}
61
68
  body.lq-talking .lqd{opacity:.55}
62
69
  @media (prefers-reduced-motion: reduce){.lqd,#lqcore{transition:none}}
70
+ /* ---- the Symbiot look on every part: glass over the liquid, chrome, a silver edge ---- */
71
+ body.lq-liquid.lq-pooled main{background:rgba(6,8,11,.86);backdrop-filter:blur(20px) saturate(1.25);border:1px solid transparent;background-clip:padding-box;box-shadow:0 0 0 1px rgba(214,222,234,.22),0 40px 90px -30px rgba(0,0,0,.85),inset 0 1px 0 rgba(255,255,255,.12)}
72
+ body.lq-liquid.lq-light.lq-pooled main{background:rgba(246,248,251,.9);box-shadow:0 0 0 1px rgba(20,30,45,.14),0 40px 90px -40px rgba(20,30,45,.45),inset 0 1px 0 #FFFFFF}
73
+ body.lq-liquid.lq-solid.lq-pooled main{backdrop-filter:none;background:var(--ink)}
74
+ body.lq-liquid .task,body.lq-liquid .agent,body.lq-liquid .rcard,body.lq-liquid .bcard,body.lq-liquid .drift,body.lq-liquid .q,body.lq-liquid .out,body.lq-liquid .tchat{background:linear-gradient(180deg,rgba(255,255,255,.075),rgba(255,255,255,.02));border:1px solid rgba(220,228,240,.16);border-radius:16px;box-shadow:inset 0 1px 0 rgba(255,255,255,.09),0 12px 30px -24px rgba(0,0,0,.9)}
75
+ body.lq-liquid.lq-light .task,body.lq-liquid.lq-light .agent,body.lq-liquid.lq-light .rcard,body.lq-liquid.lq-light .bcard,body.lq-liquid.lq-light .drift,body.lq-liquid.lq-light .q,body.lq-liquid.lq-light .out,body.lq-liquid.lq-light .tchat{background:linear-gradient(180deg,#FFFFFF,#F2F4F8);border-color:rgba(20,30,45,.12);box-shadow:inset 0 1px 0 #FFFFFF,0 12px 28px -24px rgba(20,30,45,.5)}
76
+ body.lq-liquid button.act{background:linear-gradient(180deg,#FFFFFF 0%,#D2D8E1 44%,#8E97A6 56%,#E6EAF0 100%);color:#06080B;border:1px solid rgba(255,255,255,.9);border-radius:999px;box-shadow:0 6px 18px -10px rgba(0,0,0,.7)}
77
+ body.lq-liquid button.ghost{background:rgba(255,255,255,.05);border:1px solid rgba(220,228,240,.28);border-radius:999px;color:var(--bone);backdrop-filter:blur(6px)}
78
+ body.lq-liquid.lq-light button.ghost{background:rgba(255,255,255,.7);border-color:rgba(20,30,45,.18)}
79
+ body.lq-liquid input,body.lq-liquid select,body.lq-liquid textarea{border-radius:12px;border:1px solid rgba(220,228,240,.2);background:rgba(255,255,255,.04);color:var(--bone)}
80
+ body.lq-liquid.lq-light input,body.lq-liquid.lq-light select,body.lq-liquid.lq-light textarea{background:#FFFFFF;border-color:rgba(20,30,45,.18)}
81
+ body.lq-liquid input:focus,body.lq-liquid select:focus,body.lq-liquid textarea:focus{outline:none;border-color:rgba(255,255,255,.7);box-shadow:0 0 0 3px rgba(200,210,224,.18)}
82
+ body.lq-liquid .tgroup{color:#C9D1DC;letter-spacing:.16em}
83
+ body.lq-liquid.lq-light .tgroup{color:#3B4656}
84
+ body.lq-liquid h2,body.lq-liquid h3{letter-spacing:-.01em}
85
+ body.lq-liquid .fchip.on{background:linear-gradient(180deg,#FFFFFF,#C9D1DC);color:#06080B;border-color:#FFFFFF}
86
+ body.lq-contrast.lq-liquid .task,body.lq-contrast.lq-liquid .agent{border:2px solid currentColor}
87
+
88
+ /* the work scene: tasks and agents in the liquid, with spheres revolving round what's at work */
89
+ #lqorbits{position:absolute;inset:0;pointer-events:none}
90
+ .lqorbit{position:absolute;left:0;top:0;pointer-events:none;transition:opacity .5s ease}
91
+ .lqorbit span{position:absolute;inset:0;animation:lqorbit 3.4s linear infinite}
92
+ .lqorbit span:nth-child(2){animation-duration:4.6s;animation-delay:-1.6s}
93
+ .lqorbit span:nth-child(3){animation-duration:5.8s;animation-delay:-3.9s;animation-direction:reverse}
94
+ .lqorbit i{position:absolute;left:50%;top:0;width:10px;height:10px;margin:-5px 0 0 -5px;border-radius:50%;background:radial-gradient(circle at 35% 30%,#FFFFFF 0%,#C9CFD9 35%,#6F7887 75%,#2A2F38 100%);box-shadow:0 0 8px rgba(255,255,255,.35)}
95
+ .lqorbit.ask i{background:radial-gradient(circle at 35% 30%,#FFF4E2 0%,#F2A541 50%,#7A4A10 100%)}
96
+ @keyframes lqorbit{to{transform:rotate(360deg)}}
97
+ @media (prefers-reduced-motion: reduce){.lqorbit span{animation:none}.lqorbit span:nth-child(2){transform:rotate(120deg)}.lqorbit span:nth-child(3){transform:rotate(240deg)}}
98
+ #lqgo,#lqback{display:none}
99
+ body.lq-work #lqgo.on{display:inline-flex;position:absolute;left:50%;bottom:86px;transform:translateX(-50%);z-index:4;align-items:center;gap:8px;min-height:52px;padding:0 26px;border-radius:999px;border:1px solid #FFFFFF;background:linear-gradient(180deg,#FFFFFF 0%,#D2D8E1 44%,#8E97A6 56%,#E6EAF0 100%);color:#06080B;font:700 16px var(--sans);letter-spacing:.02em;cursor:pointer;box-shadow:0 16px 40px -16px rgba(0,0,0,.75)}
100
+ body.lq-work #lqback{display:inline-flex;position:absolute;left:16px;top:52px;z-index:4;align-items:center;min-height:44px;padding:0 16px;border-radius:999px;border:1px solid rgba(220,228,240,.3);background:rgba(4,5,7,.6);backdrop-filter:blur(8px);color:#EEF2F8;font:600 14px var(--sans);cursor:pointer}
101
+ body.lq-light.lq-work #lqback{background:rgba(255,255,255,.8);color:#10151C;border-color:rgba(20,30,45,.2)}
102
+ body.lq-pooled #lqgo,body.lq-pooled #lqback,body.lq-pooled #lqorbits{display:none}
103
+ .lqd.lq-ready{border-color:#F2A541}
104
+ /* the live work of an agent: what it's doing, its to-dos, its steps, its files, its pace */
105
+ .wk{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(260px,100%),1fr));gap:12px;margin-top:10px}
106
+ .wkbox{border-radius:14px;padding:10px 12px;background:rgba(255,255,255,.035);border:1px solid rgba(220,228,240,.12)}
107
+ body.lq-light .wkbox{background:#F6F8FB;border-color:rgba(20,30,45,.1)}
108
+ .wkh{font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--faint);margin-bottom:6px;display:flex;gap:6px;align-items:center}
109
+ .wkstats{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
110
+ .wkstat{font-size:11.5px;padding:3px 9px;border-radius:999px;border:1px solid rgba(220,228,240,.2);color:var(--text);white-space:nowrap}
111
+ .wkstat b{color:var(--bone)}
112
+ .wkdoing{margin-top:10px;font-size:14px;font-weight:600;background:linear-gradient(90deg,#8C96A6 0%,#FFFFFF 40%,#8C96A6 60%,#8C96A6 100%);background-size:220% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:lqshim 2.2s linear infinite}
113
+ body.lq-light .wkdoing{background-image:linear-gradient(90deg,#4A5565 0%,#0F1720 40%,#4A5565 60%,#4A5565 100%)}
114
+ @keyframes lqshim{0%{background-position:120% 0}100%{background-position:-120% 0}}
115
+ .wktodo{display:flex;gap:8px;align-items:flex-start;font-size:12.5px;line-height:1.4;padding:3px 0}
116
+ .wktodo i{flex:none;width:12px;height:12px;margin-top:2px;border-radius:50%;border:1.5px solid var(--faint)}
117
+ .wktodo.in_progress i{border-color:var(--bone);background:conic-gradient(var(--bone) 0 50%,transparent 50%);animation:lqspin 1.4s linear infinite}
118
+ .wktodo.completed i{border-color:var(--green);background:var(--green)}
119
+ .wktodo.completed span{color:var(--faint);text-decoration:line-through}
120
+ @keyframes lqspin{to{transform:rotate(360deg)}}
121
+ .wkstep{display:flex;gap:8px;align-items:center;font-size:12.5px;padding:3px 0;min-width:0}
122
+ .wkstep svg{flex:none;opacity:.85}
123
+ .wkstep .wt{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
124
+ .wkstep .wt b{font-weight:600;color:var(--bone)}
125
+ .wkstep .wm{flex:none;font-size:11px;color:var(--faint)}
126
+ .wkstep.error .wt{color:#FF8A75}.wkstep.running .wt{color:var(--bone)}
127
+ .wktest{font-size:11px;padding:1px 7px;border-radius:999px;background:rgba(61,220,151,.14);color:var(--green);white-space:nowrap}
128
+ .wktest.bad{background:rgba(255,138,117,.16);color:#FF8A75}
129
+ .wkfile{display:flex;gap:8px;align-items:center;font-size:12px;padding:2px 0}
130
+ .wkfile .fn{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,Menlo,Consolas,monospace}
131
+ .wkfile .fb{flex:1;height:6px;border-radius:3px;background:rgba(220,228,240,.12);overflow:hidden}
132
+ .wkfile .fb i{display:block;height:100%;border-radius:3px;background:linear-gradient(90deg,#8E97A6,#FFFFFF)}
133
+ body.lq-light .wkfile .fb i{background:linear-gradient(90deg,#6F7887,#0F1720)}
134
+ .wkfinal{margin-top:10px;font-size:13px;line-height:1.55;white-space:pre-wrap}
135
+ .wkraw summary{cursor:pointer;font-size:12px;color:var(--faint);margin-top:10px}
136
+ .orb{flex:none;display:inline-block;width:14px;height:14px;border-radius:50%;background:radial-gradient(circle at 35% 30%,#FFFFFF 0%,#C9CFD9 30%,#6F7887 70%,#2A2F38 100%);box-shadow:0 0 0 1px rgba(255,255,255,.25)}
137
+ .orb.run{animation:lqpulse 1.6s ease-in-out infinite}.orb.fail{background:radial-gradient(circle at 35% 30%,#FFE1DA 0%,#FF8A75 45%,#7A2A1C 100%)}
138
+ @keyframes lqpulse{0%,100%{transform:scale(1);box-shadow:0 0 0 1px rgba(255,255,255,.25)}50%{transform:scale(1.18);box-shadow:0 0 14px 2px rgba(255,255,255,.35)}}
139
+ .msg .steps,.lqmsg .steps{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}
140
+ .steps span{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid rgba(220,228,240,.22);color:var(--faint);white-space:nowrap}
141
+ .thinking{background:linear-gradient(90deg,#8C96A6 0%,#FFFFFF 40%,#8C96A6 60%,#8C96A6 100%);background-size:220% 100%;-webkit-background-clip:text;background-clip:text;color:transparent !important;animation:lqshim 2.2s linear infinite}
142
+ body.lq-light .thinking{background-image:linear-gradient(90deg,#4A5565 0%,#0F1720 40%,#4A5565 60%,#4A5565 100%)}
143
+ @media (prefers-reduced-motion: reduce){.wkdoing,.thinking,.orb.run,.wktodo.in_progress i{animation:none}}
144
+
63
145
 
64
146
  *{box-sizing:border-box}html,body{margin:0;height:100%}
65
147
  body{background:var(--ink);color:var(--text);font-family:var(--sans);font-size:14px;display:flex;flex-direction:column}
@@ -85,9 +167,14 @@ a{color:var(--green);cursor:pointer}.hidden{display:none}
85
167
  .note{font-size:12px;margin-top:10px}.ok{color:var(--green)}.err{color:var(--amber)}
86
168
  .updatebar{display:none;align-items:center;gap:12px;padding:9px 16px;font-size:13px;font-weight:600;background:var(--green);color:var(--ink)}
87
169
  .updatebar.show{display:flex}
170
+ body.lq-liquid .updatebar.show{position:fixed;top:10px;left:50%;transform:translateX(-50%);width:max-content;max-width:calc(100vw - 24px);z-index:20;border-radius:999px;border:1px solid #FFFFFF;background:linear-gradient(180deg,#FFFFFF 0%,#D2D8E1 44%,#8E97A6 56%,#E6EAF0 100%);color:#06080B;box-shadow:0 14px 40px -14px rgba(0,0,0,.7)}
171
+ body.lq-liquid .updatebar.show.reconnect{background:var(--amber)}
172
+ body.lq-liquid #appbar.updatebar.show{top:58px}
88
173
  .updatebar.reconnect{background:var(--amber)}
89
174
  .whatsnew{display:none;padding:10px 16px;font-size:13px;background:var(--ink2);border-bottom:1px solid var(--line);max-height:40vh;overflow:auto}
90
175
  .whatsnew.show{display:block}
176
+ body.lq-liquid .whatsnew.show{position:fixed;top:60px;left:50%;transform:translateX(-50%);width:min(660px,calc(100vw - 32px));z-index:8;border:1px solid rgba(220,228,240,.25);border-radius:20px;background:rgba(6,8,11,.82);backdrop-filter:blur(16px);box-shadow:0 30px 70px -30px rgba(0,0,0,.85)}
177
+ body.lq-liquid.lq-light .whatsnew.show{background:rgba(255,255,255,.9);border-color:rgba(20,30,45,.15)}
91
178
  .updatebar button,.updatebar a.b{font:inherit;font-weight:700;border:0;border-radius:8px;padding:5px 12px;background:var(--ink);color:var(--bone);cursor:pointer;text-decoration:none}
92
179
  .ver{font-size:11px;color:var(--faint);background:var(--ink3);border:1px solid var(--line);border-radius:999px;padding:2px 8px;margin-left:2px;font-family:ui-monospace,Menlo,Consolas,monospace}
93
180
  footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
@@ -207,7 +294,10 @@ label.check input{width:auto}
207
294
  </style></head><body>
208
295
  <div id="liquid" aria-label="Symbiot">
209
296
  <canvas id="lq" aria-hidden="true"></canvas>
297
+ <div id="lqorbits"></div>
210
298
  <div id="lqdrops"></div>
299
+ <button type="button" id="lqback">&lsaquo; Home</button>
300
+ <button type="button" id="lqgo"></button>
211
301
  <button type="button" id="lqcore" title="talk to Symbiot"></button>
212
302
  <div id="lqmore"></div>
213
303
  <div id="lqtalk" aria-live="polite"></div>
@@ -439,10 +529,10 @@ edges.forEach(function(e){var a=idx[e.source],b=idx[e.target];if(!a||!b)return;v
439
529
  nodes.forEach(function(n){n.vx+=(GW/2-n.x)*0.002;n.vy+=(GH/2-n.y)*0.002;n.x+=Math.max(-9,Math.min(9,n.vx));n.y+=Math.max(-9,Math.min(9,n.vy));n.vx*=0.86;n.vy*=0.86;n.x=Math.max(30,Math.min(GW-30,n.x));n.y=Math.max(24,Math.min(GH-28,n.y));});}}
440
530
  function nbrs(id){var s={};s[id]=1;GRAPH.edges.forEach(function(e){if(e.source===id)s[e.target]=1;if(e.target===id)s[e.source]=1;});return s;}
441
531
  function render(){if(!GRAPH)return;var idx={};GRAPH.nodes.forEach(function(n){idx[n.id]=n;});var nb=sel?nbrs(sel):null;
442
- var s="<g id='vp' transform='translate("+view.x.toFixed(1)+","+view.y.toFixed(1)+") scale("+view.k.toFixed(3)+")'>";
443
- GRAPH.edges.forEach(function(e){var a=idx[e.source],b=idx[e.target];if(!a||!b)return;var op=nb?((nb[e.source]&&nb[e.target])?0.75:0.06):0.45;s+="<line x1='"+a.x.toFixed(1)+"' y1='"+a.y.toFixed(1)+"' x2='"+b.x.toFixed(1)+"' y2='"+b.y.toFixed(1)+"' stroke='#24404A' stroke-width='1' opacity='"+op+"'/>";});
444
- GRAPH.nodes.forEach(function(n){var r=Math.max(5,Math.sqrt(n.weight)*2);var col=COLORS[n.type]||"#888";var op=nb?(nb[n.id]?1:0.14):0.95;var st=(n.id===sel)?" stroke='#F4F1EA' stroke-width='2'":"";var title=esc(n.label)+(n.meta?(" - "+(n.meta.commits||0)+" commits"):"");
445
- s+="<g class='node' data-id='"+esc(n.id)+"' opacity='"+op+"'><circle cx='"+n.x.toFixed(1)+"' cy='"+n.y.toFixed(1)+"' r='"+r.toFixed(1)+"' fill='"+col+"'"+st+"><title>"+title+"</title></circle>";
532
+ var s="<defs><radialGradient id='lqsilver' cx='35%' cy='30%' r='75%'><stop offset='0%' stop-color='#FFFFFF'/><stop offset='35%' stop-color='#C9CFD9'/><stop offset='75%' stop-color='#6F7887'/><stop offset='100%' stop-color='#2A2F38'/></radialGradient></defs><g id='vp' transform='translate("+view.x.toFixed(1)+","+view.y.toFixed(1)+") scale("+view.k.toFixed(3)+")'>";
533
+ GRAPH.edges.forEach(function(e){var a=idx[e.source],b=idx[e.target];if(!a||!b)return;var op=nb?((nb[e.source]&&nb[e.target])?0.75:0.06):0.45;s+="<line x1='"+a.x.toFixed(1)+"' y1='"+a.y.toFixed(1)+"' x2='"+b.x.toFixed(1)+"' y2='"+b.y.toFixed(1)+"' stroke='"+(LQ.theme&&LQ.theme.light?'#8E97A6':'#5C6878')+"' stroke-width='1' opacity='"+op+"'/>";});
534
+ GRAPH.nodes.forEach(function(n){var r=Math.max(5,Math.sqrt(n.weight)*2);var col=COLORS[n.type]||"#888";var op=nb?(nb[n.id]?1:0.14):0.95;var title=esc(n.label)+(n.meta?(" - "+(n.meta.commits||0)+" commits"):"");
535
+ s+="<g class='node' data-id='"+esc(n.id)+"' opacity='"+op+"'><circle cx='"+n.x.toFixed(1)+"' cy='"+n.y.toFixed(1)+"' r='"+r.toFixed(1)+"' fill='url(#lqsilver)' stroke='"+col+"' stroke-width='"+(n.id===sel?2.5:1.2)+"'"+(n.id===sel?" stroke-opacity='1'":" stroke-opacity='0.7'")+"><title>"+title+"</title></circle>";
446
536
  var cls=n.type==="person"?"lbl-me":"";s+="<text x='"+n.x.toFixed(1)+"' y='"+(n.y+r+12).toFixed(1)+"' text-anchor='middle' class='"+cls+"'>"+esc(n.label)+"</text></g>";});
447
537
  document.getElementById("graph").innerHTML=s+"</g>";}
448
538
  function profileLine(g){var repos=g.nodes.filter(function(n){return n.type==="repo";});var base=g.stats.base||"your home folder";
@@ -498,7 +588,8 @@ else if(rm)rm.addEventListener('click',function(){api('/api/tasks/remove',{id:id
498
588
  // connected model, grounded in the task's repo (and its pending changes).
499
589
  function askBtn(t){var n=(t.chat||[]).length;return "<button class='ask"+(n?" has":"")+"' title='"+(n?"questions &amp; answers about this task":"ask a question about this task")+"'>&#128172;"+(n?" "+Math.ceil(n/2):"")+"</button>";}
500
590
  function taskById(id){var all=PENDTASKS.concat(ALLTASKS);for(var i=0;i<all.length;i++)if(all[i].id===id)return all[i];return null;}
501
- function chatMsgs(chat){return (chat||[]).map(function(m){return "<div class='msg "+(m.role==='user'?'q':'a')+"'>"+esc(m.text)+"</div>";}).join("");}
591
+ function stepsHtml(st){return st&&st.length?"<div class='steps'>"+st.map(function(x){return "<span>"+esc(x)+"</span>";}).join('')+"</div>":"";}
592
+ function chatMsgs(chat){return (chat||[]).map(function(m){return "<div class='msg "+(m.role==='user'?'q':'a')+"'>"+esc(m.text)+(m.role==='user'?'':stepsHtml(m.steps))+"</div>";}).join("");}
502
593
  var CHATHINT="<div class='muted' style='font-size:12px'>Ask anything about this task &mdash; what it means, how to approach it, or what the agent changed. Answers draw on the repo&#39;s commits, README and rules.</div>";
503
594
  function wireAsk(row,id){var b=row.querySelector('.ask');if(b)b.addEventListener('click',function(){toggleChat(row,id);});}
504
595
  function toggleChat(row,id){var btn=row.querySelector('.ask');var nx=row.nextElementSibling;
@@ -646,6 +737,33 @@ var LANEWORD={started:'working on it',held:'queued: that lane is busy',done:'don
646
737
  function loadLanes(){api('/api/lanes').then(function(d){var el=document.getElementById('laneslist');if(!el||!d)return;var hs=d.handoffs||[];
647
738
  el.innerHTML=hs.length?"<div class='tgroup'>Handovers <span class='tcount'>"+hs.length+"</span></div>"+hs.slice(0,12).map(function(h){
648
739
  return "<div class='task' title='"+escQ(h.result||h.error||h.text)+"'><span class='t'><b>"+esc(h.from)+"</b> &rarr; <b>"+esc(h.to)+"</b>: "+esc(h.text)+"</span><span class='rp'"+(h.status==='error'?" style='color:var(--amber)'":"")+">"+esc(LANEWORD[h.status]||h.status)+"</span></div>";}).join(''):'';});}
740
+
741
+ // ---- an agent's work, live (work.mjs parses its stream) ------------------------------
742
+ var WKICON={read:"<path d='M2 4h5l1 1h6v8H2z'/>",edit:"<path d='M3 13l1-3 7-7 2 2-7 7z'/>",run:"<path d='M3 5l3 3-3 3M8 12h5'/>",search:"<circle cx='7' cy='7' r='4'/><path d='M10 10l3 3'/>",web:"<circle cx='8' cy='8' r='5.5'/><path d='M2.5 8h11M8 2.5c2 2 2 9 0 11M8 2.5c-2 2-2 9 0 11'/>",agent:"<path d='M8 2l1.5 4.5L14 8l-4.5 1.5L8 14l-1.5-4.5L2 8l4.5-1.5z'/>",connector:"<path d='M6 2v4M10 2v4M4 6h8v2a4 4 0 0 1-8 0zM8 12v2'/>",tool:"<circle cx='8' cy='8' r='2.5'/>"};
743
+ function wkIcon(k){return "<svg width='14' height='14' viewBox='0 0 16 16' fill='none' stroke='currentColor' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' aria-hidden='true'>"+(WKICON[k]||WKICON.tool)+"</svg>";}
744
+ function wkMs(ms){if(ms==null)return '';return ms<1000?ms+'ms':ms<60000?(ms/1000).toFixed(ms<10000?1:0)+'s':Math.round(ms/60000)+'m';}
745
+ function wkRing(done,total){var r=15,c=2*Math.PI*r,f=total?done/total:0;return "<svg width='40' height='40' viewBox='0 0 40 40' aria-label='"+done+" of "+total+" tasks done'><defs><linearGradient id='wkg' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#FFFFFF'/><stop offset='1' stop-color='#6F7887'/></linearGradient></defs><circle cx='20' cy='20' r='"+r+"' fill='none' stroke='rgba(140,150,166,.25)' stroke-width='4'/><circle cx='20' cy='20' r='"+r+"' fill='none' stroke='url(#wkg)' stroke-width='4' stroke-linecap='round' stroke-dasharray='"+(c*f).toFixed(1)+" "+c.toFixed(1)+"' transform='rotate(-90 20 20)'/><text x='20' y='24' text-anchor='middle' font-size='10' font-weight='700' fill='currentColor'>"+done+"/"+total+"</text></svg>";}
746
+ function wkSpark(p){if(!p||p.length<2)return '';var m=Math.max.apply(null,p)||1,w=120,h=26,pts=p.map(function(v,i){return (i*w/(p.length-1)).toFixed(1)+','+(h-2-(v/m)*(h-4)).toFixed(1);}).join(' ');return "<svg width='"+w+"' height='"+h+"' viewBox='0 0 "+w+" "+h+"' aria-label='steps per minute'><polyline points='"+pts+"' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linejoin='round' opacity='.85'/></svg>";}
747
+ function workHtml(a){var w=a.work,pg=a.progress,run=a.status==='running',h='';
748
+ if(!w){if(run)h+="<div class='bar'><i></i></div>";if(pg)h+="<div class='wkstats'><span class='wkstat'><b>"+pg.done+"/"+pg.total+"</b> tasks ticked</span></div>";return h;}
749
+ var stats=[];if(w.model)stats.push("<span class='wkstat'>"+esc(w.model.split('[')[0])+"</span>");stats.push("<span class='wkstat'><b>"+w.count+"</b> step"+(w.count===1?'':'s')+"</span>");
750
+ if(w.turns!=null)stats.push("<span class='wkstat'><b>"+w.turns+"</b> turns</span>");if(w.tokens)stats.push("<span class='wkstat'><b>"+(w.tokens>999999?(w.tokens/1e6).toFixed(1)+'M':w.tokens>999?Math.round(w.tokens/1000)+'k':w.tokens)+"</b> tokens</span>");
751
+ if(w.cost!=null)stats.push("<span class='wkstat'><b>$"+w.cost.toFixed(2)+"</b></span>");if(w.tests)stats.push("<span class='wktest"+(w.tests.failed?' bad':'')+"'>"+w.tests.passed+" passed &middot; "+w.tests.failed+" failed</span>");if(w.errors)stats.push("<span class='wktest bad'>"+w.errors+" error"+(w.errors>1?'s':'')+"</span>");
752
+ if(run&&w.doing)h+="<div class='wkdoing'>"+esc(w.doing)+"&hellip;</div>";
753
+ var first=(w.final||'').split(String.fromCharCode(10))[0];if(!run&&first)h+="<div class='wkfinal'>"+esc(first.length>180?first.slice(0,179)+'\u2026':first)+"</div>";
754
+ var boxes=[];
755
+ if(w.todos&&w.todos.length||pg){var td="<div class='wkbox'><div class='wkh'>"+(pg?wkRing(pg.done,pg.total):'')+"<span>its to-do list</span></div>";
756
+ (w.todos||[]).forEach(function(t){td+="<div class='wktodo "+esc(t.status)+"'><i></i><span>"+esc(t.status==='in_progress'?t.active:t.text)+"</span></div>";});
757
+ if(!(w.todos||[]).length)td+="<div class='muted' style='font-size:12px'>No list of its own: "+pg.done+" of "+pg.total+" tasks ticked in TASKS.md.</div>";boxes.push(td+"</div>");}
758
+ if(w.steps&&w.steps.length){var sp="<div class='wkbox'><div class='wkh'><span>what it did</span><span style='margin-left:auto'>"+wkSpark(w.pace)+"</span></div>";
759
+ w.steps.slice(-10).forEach(function(x){sp+="<div class='wkstep "+esc(x.status)+"'>"+wkIcon(x.kind)+"<span class='wt'><b>"+esc(x.verb)+"</b> "+esc(x.target||'')+"</span>"+(x.tests?"<span class='wktest"+(x.tests.failed?' bad':'')+"'>"+x.tests.passed+"&#10003; "+x.tests.failed+"&#10007;</span>":"")+"<span class='wm'>"+(x.status==='running'?'&hellip;':wkMs(x.ms))+"</span></div>";});
760
+ if(w.count>10)sp+="<div class='muted' style='font-size:11px;margin-top:4px'>and "+(w.count-10)+" earlier step"+(w.count-10>1?'s':'')+"</div>";boxes.push(sp+"</div>");}
761
+ var fs=Object.keys(w.files||{});if(fs.length){var mx=Math.max.apply(null,fs.map(function(f){return w.files[f];}))||1,fb="<div class='wkbox'><div class='wkh'><span>files it changed</span></div>";
762
+ fs.sort(function(x,y){return w.files[y]-w.files[x];}).slice(0,8).forEach(function(f){fb+="<div class='wkfile'><span class='fn'>"+esc(f)+"</span><span class='fb'><i style='width:"+Math.round(100*w.files[f]/mx)+"%'></i></span><span class='wm'>"+w.files[f]+"&times;</span></div>";});boxes.push(fb+"</div>");}
763
+ var todoBox=boxes.length&&(w.todos&&w.todos.length||pg)?boxes.shift():'';
764
+ if(todoBox)h+="<div class='wk'>"+todoBox+"</div>";
765
+ h+="<details class='wkraw'><summary>details: steps, files, cost</summary><div class='wkstats'>"+stats.join('')+"</div>"+(boxes.length?"<div class='wk'>"+boxes.join('')+"</div>":"")+(!run&&w.final?"<div class='wkfinal'>"+esc(w.final)+"</div>":"")+"</details>";
766
+ return h;}
649
767
  function loadAgents(){loadLanes();api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
650
768
  if(!list||!list.length){AGENTLIST=[];el.innerHTML="<div class='muted' style='margin-top:12px'>No agents yet. In <b>Tasks</b>, tick ideas and hit <b>Send to repos</b> (with an agent command set in Settings) &mdash; you'll watch it work here.</div>";stopAgentsPoll();return;}
651
769
  var anyRunning=list.some(function(a){return a.status==='running';});
@@ -660,8 +778,8 @@ if(nQs(a))st+=" &middot; <span style='color:var(--amber)'>needs your answers</sp
660
778
  if(a.held!=null){var hn=a.held.length,ht=a.held.map(function(t){return "&bull; "+esc(t).replace(/'/g,'&#39;');}).join('&#10;');
661
779
  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.");
662
780
  st+=" &middot; <span style='color:var(--amber);cursor:help' title='"+ht+"'>&#9208; "+(hn?hn+" task"+(hn>1?"s":"")+" held":"tasks held")+"</span>";}
663
- var b="<div class='dh'><span class='adot "+cls+"'></span><span class='dn'>"+esc(a.name)+"</span><span class='dd'>"+st+"</span></div>";
664
- if(a.status==='running')b+="<div class='bar'><i></i></div>";
781
+ var b="<div class='dh'><span class='orb "+cls+"'></span><span class='dn'>"+esc(a.name)+"</span><span class='dd'>"+st+"</span></div>";
782
+ b+=workHtml(a);
665
783
  var ch=a.changed;
666
784
  if(ch&&(ch.dirty||ch.stat||(ch.commits&&ch.commits.length))){
667
785
  var parts=[];
@@ -673,7 +791,7 @@ if(ch&&(ch.dirty||ch.stat||(ch.commits&&ch.commits.length))){
673
791
  b+="<div class='changed muted'>No file changes detected (the agent may have only planned or asked).</div>";
674
792
  }
675
793
  b+=waitHtml(a)+askHtml(a);
676
- b+="<pre class='alogout'>"+esc((a.tail&&a.tail.trim())||'(waiting for output…)')+"</pre>";
794
+ 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>");
677
795
  return "<div class='agent'>"+b+"</div>";}).join("");
678
796
  el.querySelectorAll('.alogout').forEach(function(p){p.scrollTop=p.scrollHeight;});
679
797
  restoreDrafts(el);wireAsks(el);wireWaits(el);
@@ -1165,6 +1283,33 @@ function doUpdate(){updBusy=true;try{localStorage.setItem('symbiot_update_tried'
1165
1283
  setInterval(heartbeat,4000);heartbeat(true);
1166
1284
  window.addEventListener('focus',function(){heartbeat(true);}); // re-check for updates when you come back to the window
1167
1285
 
1286
+
1287
+ // ---- the work scene ------------------------------------------------------------------
1288
+ // Tasks and agents, in the liquid: what each agent is doing (spheres revolve round
1289
+ // it while it works), what's done and waiting for your OK, and what's waiting its
1290
+ // turn. One Go starts what's waiting. A tap opens the details; nothing else to read.
1291
+ function lqWork(){LQ.scene='work';LQ.mode='aware';var b=document.body;if(b&&b.classList){b.classList.add('lq-work');b.classList.remove('lq-pooled');}lqLoadWork();
1292
+ if(LQ.workTimer)clearInterval(LQ.workTimer);LQ.workTimer=setInterval(function(){if(LQ.scene==='work'&&LQ.mode!=='pool')lqLoadWork();},5000);}
1293
+ function lqHome(){LQ.scene='home';var b=document.body;if(b&&b.classList){b.classList.remove('lq-work');b.classList.remove('lq-pooled');}if(LQ.workTimer){clearInterval(LQ.workTimer);LQ.workTimer=null;}LQ.mode='aware';lqLoad(true);}
1294
+ function lqLoadWork(){api('/api/work').then(function(w){LQ.workData=w||{};lqBuildWork();}).catch(function(){});}
1295
+ function lqBuildWork(){var w=LQ.workData||{},S=lqSize(),cx=S.w/2,cy=S.h*0.5,list=[];LQ.bw=S.w;LQ.bh=S.h;
1296
+ 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-160)/400)),rs=Math.min(S.s,(vx+vy)/1.5);
1297
+ var run=(w.running||[]).slice(0,3),ready=(w.ready||[]).slice(0,2),wait=(w.waiting||[]).slice(0,5),extra=(w.waitingCount||0)-wait.length;
1298
+ run.forEach(function(r,i){var ang=Math.PI+(i+1)*Math.PI/(run.length+1);var pg=r.progress?' · '+r.progress.done+' of '+r.progress.total+' done':'';
1299
+ list.push({id:r.id,kind:'run',shape:'agents',ask:r.waiting,title:r.doing,sub:r.name+pg+(r.waiting?' · has a question for you':''),r:58*rs,tx:cx+Math.cos(ang)*190*vx,ty:cy+Math.sin(ang)*130*vy});});
1300
+ ready.forEach(function(r,i){var ang=-Math.PI/2+(i-(ready.length-1)/2)*0.9;list.push({id:r.id,kind:'ready',shape:'tasks',title:'Ready for your OK',sub:r.repo+(r.count?' · '+r.count+' task'+(r.count>1?'s':'')+' done':''),r:48*rs,tx:cx+Math.cos(ang)*300*vx,ty:cy+Math.sin(ang)*300*vy});});
1301
+ wait.forEach(function(t,i){var ang=Math.PI/2+(i-(wait.length-1)/2)*0.55;list.push({id:t.id,kind:'wait',shape:'tasks',title:t.text,sub:t.repo+(t.busy?' · after the current one':' · waiting'),r:30*rs,tx:cx+Math.cos(ang)*300*vx,ty:cy+Math.sin(ang)*260*vy});});
1302
+ if(extra>0)list.push({id:'morewait',kind:'more',shape:'tasks',title:'+'+extra+' more waiting',sub:'see them all',r:24*rs,tx:S.w-Math.max(80,S.w*0.1),ty:S.h-230});
1303
+ lqRelax(list,S,cx,cy,60*rs);
1304
+ var old={};LQ.drops.forEach(function(d){old[d.id]=d;});
1305
+ 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;});
1306
+ var nr=(w.running||[]).length,nw=w.waitingCount||0,nk=(w.ready||[]).length;
1307
+ LQ.coreText=(nr?nr+' at work':'Nobody at work')+(nw?' · '+nw+' waiting':'')+(nk?' · '+nk+' ready for your OK':'');
1308
+ var go=$('lqgo');if(go){var n=w.canGo||0;go.textContent=n?'Go \u00b7 start '+n+' task'+(n>1?'s':''):'';if(go.classList)go.classList.toggle('on',n>0);}
1309
+ lqLabels();}
1310
+ function lqGoWork(){var go=$('lqgo');if(go)go.disabled=true;api('/api/work/go',{}).then(function(r){if(go)go.disabled=false;
1311
+ var t=r&&r.error?r.error:r.started||r.queued?(r.started?'Started '+r.started:'')+(r.started&&r.queued?', ':'')+(r.queued?r.queued+' queued behind work already going':'')+'.':(r&&r.note)||'Nothing waiting to start.';
1312
+ LQ.talk.push({me:false,text:t});LQ.talk=LQ.talk.slice(-8);lqTalkShow(false);LQ.ripple=[0.5,0.5,LQ.t];lqLoadWork();}).catch(function(){if(go)go.disabled=false;});}
1168
1313
  // ---- the liquid: the app's home ---------------------------------------------------
1169
1314
  // One silver surface is the app. Its droplets: what only you can do, what's new on
1170
1315
  // what you watch, and the parts of the app, sized and placed from how you use them
@@ -1184,15 +1329,21 @@ if(a.style.color){var ca=getComputedStyle(a).color,cb=getComputedStyle(b).color;
1184
1329
  var bd=document.body;if(bd&&bd.classList){bd.classList.toggle('lq-light',th.light);bd.classList.toggle('lq-contrast',th.contrast);bd.classList.toggle('lq-solid',th.solid||th.contrast);bd.classList.toggle('lq-forced',th.forced);}
1185
1330
  LQ.theme=th;return th;}
1186
1331
  function lqSize(){var el=$('liquid');var w=(el&&el.clientWidth)||window.innerWidth||1280,h=(el&&el.clientHeight)||window.innerHeight||800;return {w:w,h:h,s:Math.max(0.55,Math.min(1.15,Math.min(w,h)/860))};}
1187
- function lqLoad(commit){Promise.all([api('/api/adapt?from='+encodeURIComponent(LQ.last)+(commit?'&commit=1':'')+(LQ.touch?'&touch=1':'')),api('/api/home')]).then(function(r){LQ.adapt=r[0]||{};LQ.home=r[1]||{};lqBuild();}).catch(function(){});}
1332
+ function lqLoad(commit){if(LQ.scene==='work'){lqLoadWork();return;}Promise.all([api('/api/adapt?from='+encodeURIComponent(LQ.last)+(commit?'&commit=1':'')+(LQ.touch?'&touch=1':'')),api('/api/home')]).then(function(r){LQ.adapt=r[0]||{};LQ.home=r[1]||{};lqBuild();}).catch(function(){});}
1188
1333
  function lqBuild(){var a=LQ.adapt||{},h=LQ.home||{},S=lqSize(),cx=S.w/2,cy=S.h*0.47,list=[];LQ.bw=S.w;LQ.bh=S.h;
1189
1334
  var you=(h.you||[]).slice(0,3),feeds=(h.feeds||[]).slice(0,2),lay=(a.layout&&a.layout.items)||[],more=((a.layout&&a.layout.more)||[]).slice();
1190
1335
  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-160)/400));var rs=Math.min(S.s,(vx+vy)/1.5);var room=Math.max(3,LQ_MAX-you.length-feeds.length-1);lay.slice(room).forEach(function(it){more.push(it.id);});
1191
1336
  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:'',r:it.r*rs,tx:cx+Math.cos(it.angle)*it.d*vx,ty:cy+Math.sin(it.angle)*it.d*vy});});
1192
1337
  you.forEach(function(y,i){var ang=-Math.PI/2+(i-(you.length-1)/2)*0.6;list.push({id:y.id,kind:'you',shape:y.shape,title:y.title,sub:y.sub,r:50*rs,tx:cx+Math.cos(ang)*230*vx,ty:cy+Math.sin(ang)*230*vy*1.05,tendril:true});});
1193
1338
  feeds.forEach(function(f,i){var ang=Math.PI/2+(i-(feeds.length-1)/2)*0.7;list.push({id:f.id,kind:'feed',shape:f.shape,title:f.title,sub:f.sub,r:(28+5*Math.min(f.count||1,5))*rs,tx:cx+Math.cos(ang)*300*vx,ty:cy+Math.sin(ang)*300*vy});});
1194
- if(more.length)list.push({id:'more',kind:'more',title:'more',sub:more.map(function(m){return LQNAMES[m]||m;}).join(' · '),more:more,r:24*S.s,tx:S.w-Math.max(70,S.w*0.08),ty:150});
1195
- lqRelax(list,S,cx,cy,92*rs);var old={};LQ.drops.forEach(function(d){old[d.id]=d;});
1339
+ // Solve it. If this screen can't hold them all clear of each other (lqRelax), the
1340
+ // least likely part goes under "more" (Hick: fewer, not cramped) and it solves
1341
+ // again. What only you can do and your feeds always stay out.
1342
+ var base=list.slice(),cur=null,tries=0;base.forEach(function(d){d.r0=d.r;d.tx0=d.tx;d.ty0=d.ty;});
1343
+ for(;;){cur=base.slice();if(more.length)cur.push({id:'more',kind:'more',title:'more',sub:more.map(function(m){return LQNAMES[m]||m;}).join(' · '),more:more.slice(),r0:24*S.s,tx0:S.w-Math.max(70,S.w*0.08),ty0:150});
1344
+ cur.forEach(function(d){d.r=d.r0;d.tx=d.tx0;d.ty=d.ty0;});if(lqRelax(cur,S,cx,cy,92*rs)||tries++>=8)break;
1345
+ var q=-1;for(var z=base.length-1;z>=0;z--)if(base[z].kind==='shape'){q=z;break;}if(q<0)break;more.push(base[q].shape);base.splice(q,1);}
1346
+ list=cur;var old={};LQ.drops.forEach(function(d){old[d.id]=d;});
1196
1347
  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;});
1197
1348
  var n=(h.you||[]).length,w=h.working||0;
1198
1349
  LQ.coreText=n?(n+(n>1?' things need':' thing needs')+' only you'+(w?' · '+w+' agent'+(w>1?'s':'')+' working':'')):(w?w+' agent'+(w>1?'s':'')+' working · nothing needs you':'All handled');
@@ -1202,36 +1353,58 @@ lqLabels();}
1202
1353
  // Force-directed layout, solved once per change: push overlapping targets apart
1203
1354
  // (each droplet's circle plus its label below or above), and off the core, within
1204
1355
  // the screen. Deterministic, so the same data gives the same layout.
1205
- function lqRelax(L,S,cx,cy,coreR){var n=L.length;for(var it=0;it<160;it++){var moved=0;for(var i=0;i<n;i++){var a=L[i];
1206
- for(var j=i+1;j<n;j++){var b=L[j],dx=a.tx-b.tx,dy=a.ty-b.ty,dd=Math.sqrt(dx*dx+dy*dy)||0.01,mn=a.r+b.r+70+(Math.abs(dx)<150?48:0);if(dd<mn){var f=(mn-dd)/2/dd;a.tx+=dx*f;a.ty+=dy*f;b.tx-=dx*f;b.ty-=dy*f;moved++;}}
1207
- var cdx=a.tx-cx,cdy=a.ty-cy,cd=Math.sqrt(cdx*cdx+cdy*cdy)||0.01,cm=a.r+coreR+70;if(cd<cm){a.tx+=cdx*(cm-cd)/cd;a.ty+=cdy*(cm-cd)/cd;moved++;}
1208
- a.tx=Math.max(a.r+60,Math.min(S.w-a.r-60,a.tx));a.ty=Math.max(140+a.r,Math.min(S.h-200-a.r,a.ty));}if(!moved)break;}}
1356
+ // The one rule for how far apart two droplets sit: past where their metal would
1357
+ // bridge (r²/d² fields sum to 1 midway at d = 2·√(r1²+r2²)), with a margin so the
1358
+ // wobble can't flicker a bridge open and shut, and room for their labels when
1359
+ // they share a column. Used once, when the layout is solved; nothing fights it live.
1360
+ // Tags float on their spheres, so two on the same row also keep a tag's width apart.
1361
+ function lqSep(a,b,dx,dy){var m=Math.max(a.r+b.r+70,2.4*Math.sqrt(a.r*a.r+b.r*b.r))+(Math.abs(dx)<150?48:0);return dy!=null&&Math.abs(dy)<56?Math.max(m,232):m;}
1362
+ // Solve the layout: push targets apart until every pair keeps lqSep (and clear
1363
+ // of the core), within the screen. If they can't all fit, every droplet gives up
1364
+ // a little of its size (the liquid is conserved, not crowded) and it solves again.
1365
+ function lqRelax(L,S,cx,cy,coreR){var n=L.length;
1366
+ for(var pass=0;pass<6;pass++){
1367
+ for(var it=0;it<220;it++){var moved=0;for(var i=0;i<n;i++){var a=L[i];
1368
+ for(var j=i+1;j<n;j++){var b=L[j],dx=a.tx-b.tx,dy=a.ty-b.ty,dd=Math.sqrt(dx*dx+dy*dy)||0.01,mn=lqSep(a,b,dx,dy);if(dd<mn){var f=(mn-dd)/2/dd;a.tx+=dx*f;a.ty+=dy*f;b.tx-=dx*f;b.ty-=dy*f;moved++;}}
1369
+ var cdx=a.tx-cx,cdy=a.ty-cy,cd=Math.sqrt(cdx*cdx+cdy*cdy)||0.01,cm=Math.max(a.r+coreR+70,2.4*Math.sqrt(a.r*a.r+coreR*coreR));if(cd<cm){a.tx+=cdx*(cm-cd)/cd;a.ty+=cdy*(cm-cd)/cd;moved++;}
1370
+ a.tx=Math.max(a.r+60,Math.min(S.w-a.r-60,a.tx));a.ty=Math.max(140+a.r,Math.min(S.h-200-a.r,a.ty));}if(!moved)break;}
1371
+ var bad=false;for(var i2=0;i2<n&&!bad;i2++)for(var j2=i2+1;j2<n;j2++){var p=L[i2],q=L[j2],ddx=p.tx-q.tx,ddy=p.ty-q.ty;if(Math.sqrt(ddx*ddx+ddy*ddy)<lqSep(p,q,ddx,ddy)-1){bad=true;break;}}
1372
+ if(!bad)return true;L.forEach(function(x){x.r*=0.9;});coreR*=0.9;}return false;}
1209
1373
  function lqLabels(){var el=$('lqdrops');if(!el)return;
1210
- el.innerHTML=LQ.drops.map(function(d,i){return "<button type='button' class='lqd lq-"+d.kind+"' data-i='"+i+"'><span>"+esc(d.title)+"</span>"+(d.sub?"<small>"+esc(d.sub)+"</small>":"")+"</button>";}).join('');
1211
- LQ.btns=[];el.querySelectorAll('.lqd').forEach(function(b){LQ.btns.push(b);b.addEventListener('click',function(ev){lqOpen(LQ.drops[+b.getAttribute('data-i')],ev);});});
1374
+ el.innerHTML=LQ.drops.map(function(d,i){return "<div class='lqd lq-"+d.kind+(LQ.openTag===d.id?' open':'')+"' data-i='"+i+"'><button type='button' class='lt' aria-expanded='"+(LQ.openTag===d.id)+"'><span>"+esc(d.title)+"</span></button>"+(d.sub?"<small>"+esc(d.sub)+"</small>":"")+"<button type='button' class='lgo'>Open &rsaquo;</button></div>";}).join('');
1375
+ LQ.btns=[];LQ.lts=[];el.querySelectorAll('.lqd').forEach(function(b){LQ.btns.push(b);var d=LQ.drops[+b.getAttribute('data-i')],lt=b.querySelector('.lt'),go=b.querySelector('.lgo');LQ.lts.push(lt);
1376
+ if(lt)lt.addEventListener('click',function(ev){if(d.kind==='more'){lqOpen(d,ev);return;}lqTag(d.id);});
1377
+ if(go)go.addEventListener('click',function(ev){LQ.openTag=null;lqOpen(d,ev);});});
1378
+ var oe=$('lqorbits');if(oe){oe.innerHTML=LQ.drops.map(function(d,i){return d.kind==='run'?"<div class='lqorbit"+(d.ask?' ask':'')+"' data-i='"+i+"'><span><i></i></span><span><i></i></span><span><i></i></span></div>":'';}).join('');LQ.orbs=[];oe.querySelectorAll('.lqorbit').forEach(function(o){LQ.orbs.push(o);});}
1379
+ LQ.drops.forEach(function(d){if(d.x===d.tx&&d.y===d.ty)return;if(!LQ.frame){d.x=d.tx;d.y=d.ty;d.cr=d.r;}});lqStep();
1212
1380
  var c=$('lqcore');if(c)c.textContent=LQ.coreText||'';}
1381
+ // Tapping a sphere (or its name) opens its tag; tapping it again, or elsewhere, closes it.
1382
+ function lqTag(id){LQ.openTag=LQ.openTag===id?null:id;lqAct();LQ.btns.forEach(function(b,i){var d=LQ.drops[+b.getAttribute('data-i')],on=!!d&&d.id===LQ.openTag;if(b.classList)b.classList.toggle('open',on);var lt=LQ.lts&&LQ.lts[i];if(lt&&lt.setAttribute)lt.setAttribute('aria-expanded',on?'true':'false');});}
1213
1383
  function lqVia(ev){return LQ.talking?'talk':ev&&ev.pointerType==='touch'?'touch':ev&&ev.detail===0?'key':'click';}
1214
1384
  function lqUse(shape,ev,via){api('/api/adapt/use',{shape:shape,from:LQ.last,via:via||lqVia(ev)});LQ.last=shape;}
1215
1385
  function lqOpen(d,ev){if(!d)return;lqAct();
1216
1386
  if(d.kind==='more'){LQ.moreOpen=!LQ.moreOpen;lqMore(d);return;}
1217
- var S=lqSize();LQ.ripple=[d.x/S.w,d.y/S.h,LQ.t];lqUse(d.shape||'board',ev);lqPool(d.shape||'board');}
1387
+ var S=lqSize();LQ.ripple=[d.x/S.w,d.y/S.h,LQ.t];if(LQ.scene==='work'){lqPool(d.shape||'agents');return;}lqUse(d.shape||'board',ev);lqGo(d.shape||'board');}
1218
1388
  function lqMore(d){var el=$('lqmore');if(!el)return;if(!LQ.moreOpen||!d){el.innerHTML='';return;}
1219
- el.innerHTML=(d.more||[]).map(function(s){return "<button type='button' class='lqd lqm' style='position:static' data-s='"+s+"'><span>"+esc(LQNAMES[s]||s)+"</span></button>";}).join('');
1220
- el.querySelectorAll('.lqm').forEach(function(b){b.addEventListener('click',function(ev){var s=b.getAttribute('data-s');LQ.moreOpen=false;el.innerHTML='';lqUse(s,ev);lqPool(s);});});}
1389
+ el.innerHTML=(d.more||[]).map(function(s){return "<button type='button' class='lqmb lqm' data-s='"+s+"'>"+esc(LQNAMES[s]||s)+"</button>";}).join('');
1390
+ el.querySelectorAll('.lqm').forEach(function(b){b.addEventListener('click',function(ev){var s=b.getAttribute('data-s');LQ.moreOpen=false;el.innerHTML='';lqUse(s,ev);lqGo(s);});});}
1391
+ function lqGo(shape){if(shape==='tasks'||shape==='agents'){lqWork();return;}lqPool(shape);}
1221
1392
  function lqPool(shape){tabPicked=true;setTab(shape);var b=document.body;if(b&&b.classList)b.classList.add('lq-pooled');LQ.mode='pool';var m=document.querySelector('main');if(m&&m.focus)m.focus();}
1222
1393
  function lqSink(){var b=document.body;if(b&&b.classList)b.classList.remove('lq-pooled');LQ.mode='aware';lqAct();lqLoad(false);}
1223
1394
  function lqAct(){LQ.lastAct=Date.now();if(LQ.mode==='rest'){LQ.mode='aware';lqLoad(true);}}
1224
1395
  function lqTalkMode(on){LQ.talking=on;var b=document.body;if(b&&b.classList)b.classList.toggle('lq-talking',on);}
1225
- function lqTalkShow(wait){var el=$('lqtalk');if(!el)return;el.innerHTML=LQ.talk.map(function(m){return "<div class='lqmsg"+(m.me?' me':'')+"'>"+esc(m.text)+"</div>";}).join('')+(wait?"<div class='lqmsg'>&hellip;</div>":'');if(el.scrollHeight)el.scrollTop=el.scrollHeight;}
1396
+ function lqTalkShow(wait){var el=$('lqtalk');if(!el)return;el.innerHTML=LQ.talk.map(function(m){return "<div class='lqmsg"+(m.me?' me':'')+"'>"+esc(m.text)+stepsHtml(m.steps)+"</div>";}).join('')+(wait?"<div class='lqmsg thinking'>Thinking: recalling what it knows, reading what's here&hellip;</div>":'');if(el.scrollHeight)el.scrollTop=el.scrollHeight;}
1226
1397
  function lqSay(){var i=$('lqask');var q=((i&&i.value)||'').trim();if(!q)return;i.value='';lqAct();
1227
1398
  var low=q.toLowerCase().replace(/^(please |can you |could you )/,''),hit='';
1228
1399
  Object.keys(LQNAMES).forEach(function(k){var nm=LQNAMES[k].toLowerCase();if(hit)return;['open ','show ','go to ','take me to '].forEach(function(v){if(low.indexOf(v+nm)===0||low.indexOf(v+'my '+nm)===0)hit=k;});if(low===nm)hit=k;});
1229
- if(hit){lqTalkMode(true);lqUse(hit,null,'talk');lqPool(hit);return;}
1400
+ if(hit){lqTalkMode(true);lqUse(hit,null,'talk');lqGo(hit);return;}
1401
+ if(/^(go|start|go for it|start them|start it)[.! ]*$/.test(low)){lqTalkMode(true);lqGoWork();return;}
1402
+ if(/^(home|back)[.! ]*$/.test(low)&&LQ.scene==='work'){lqHome();return;}
1230
1403
  LQ.talk.push({me:true,text:q});LQ.talk=LQ.talk.slice(-8);lqTalkShow(true);
1231
- api('/api/home/ask',{question:q}).then(function(r){LQ.talk.push({me:false,text:r.error==='not-connected'?'Connect an AI in Settings to talk to me. Say "open settings".':(r.answer||r.error||'(no answer)')});LQ.talk=LQ.talk.slice(-8);lqTalkShow(false);LQ.ripple=[0.5,0.47,LQ.t];lqLoad(false);}).catch(function(e){LQ.talk.push({me:false,text:String((e&&e.message)||e)});lqTalkShow(false);});}
1404
+ api('/api/home/ask',{question:q}).then(function(r){LQ.talk.push({me:false,steps:r.steps||[],text:r.error==='not-connected'?'Connect an AI in Settings to talk to me. Say "open settings".':(r.answer||r.error||'(no answer)')});LQ.talk=LQ.talk.slice(-8);lqTalkShow(false);LQ.ripple=[0.5,0.47,LQ.t];lqLoad(false);}).catch(function(e){LQ.talk.push({me:false,text:String((e&&e.message)||e)});lqTalkShow(false);});}
1232
1405
  // The physics: each droplet a critically damped spring to its place (no wobble,
1233
1406
  // no overshoot), pushed off its neighbours and the core where they'd overlap.
1234
- function lqStep(){var S0=lqSize();if(LQ.adapt&&(S0.w!==LQ.bw||S0.h!==LQ.bh))lqBuild();var S=S0,cx=S.w/2,cy=S.h*0.47,k=0.022,c=2*Math.sqrt(k),th=LQ.theme,still=th.still,rest=LQ.mode==='rest',pool=LQ.mode==='pool',talk=LQ.talking&&!pool;
1407
+ function lqStep(){var S0=lqSize();if(S0.w!==LQ.bw||S0.h!==LQ.bh){if(LQ.scene==='work'&&LQ.workData)lqBuildWork();else if(LQ.adapt)lqBuild();}var S=S0,cx=S.w/2,cy=S.h*0.47,k=0.022,c=2*Math.sqrt(k),th=LQ.theme,still=th.still,rest=LQ.mode==='rest',pool=LQ.mode==='pool',talk=LQ.talking&&!pool;
1235
1408
  var core=LQ.core,ctx=cx,cty=talk?S.h-44:cy,ctr=(rest?140:pool?50:talk?54+30*(LQ.talkWeight||0.35):92)*S.s;
1236
1409
  if(!core.x){core.x=cx;core.y=cy;}core.x+=(ctx-core.x)*(still?1:0.06);core.y+=(cty-core.y)*(still?1:0.06);core.cr+=(ctr-core.cr)*(still?1:0.05);
1237
1410
  var D=LQ.drops,N=D.length;
@@ -1240,17 +1413,16 @@ if(rest){tx=cx+Math.cos(i*1.3)*16;ty=cy+Math.sin(i*1.3)*16;tr=d.r*0.6;}
1240
1413
  else if(pool){tx=cx+(i-(N-1)/2)*40*S.s;ty=S.h-40;tr=13*S.s;}
1241
1414
  else if(talk){ty=S.h*0.12+(d.ty-S.h*0.12)*0.55;}
1242
1415
  var ax=k*(tx-d.x)-c*d.vx,ay=k*(ty-d.y)-c*d.vy;
1243
- if(!rest&&!pool){for(var j=0;j<N;j++){if(i===j)continue;var e=D[j],dx=d.x-e.x,dy=d.y-e.y,dd=Math.sqrt(dx*dx+dy*dy)||1,mn=d.cr+e.cr+78+(Math.abs(dx)<((d.lw||110)+(e.lw||110))/2?52:0);if(dd<mn){var f=0.06*(mn-dd)/dd;ax+=dx*f;ay+=dy*f;}}
1244
- var dx0=d.x-core.x,dy0=d.y-core.y,d0=Math.sqrt(dx0*dx0+dy0*dy0)||1,m0=d.cr+core.cr+80;if(d0<m0){var f0=0.06*(m0-d0)/d0;ax+=dx0*f0;ay+=dy0*f0;}}
1245
1416
  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.vy;}
1246
1417
  if(!rest&&!pool){d.x=Math.max(d.cr+12,Math.min(S.w-d.cr-12,d.x));d.y=Math.max(140+d.cr,Math.min(S.h-150-d.cr-50,d.y));}
1247
1418
  d.cr+=(tr-d.cr)*(still?1:0.06);
1248
- var b=LQ.btns[i];if(b&&b.offsetWidth)d.lw=b.offsetWidth;if(b&&b.style){var up=d.y<core.y-core.cr*0.3;b.style.transform='translate('+Math.round(d.x)+'px,'+Math.round(up?d.y-d.cr-10:d.y+d.cr+10)+'px) translateX(-50%)'+(up?' translateY(-100%)':'');var show=!rest&&!pool;b.style.opacity=show?'1':'0';b.style.pointerEvents=show?'auto':'none';b.tabIndex=show?0:-1;}}
1419
+ var b=LQ.btns[i];if(b&&b.offsetWidth)d.lw=b.offsetWidth;if(b&&b.style){b.style.transform='translate('+Math.round(d.x)+'px,'+Math.round(d.y)+'px) translate(-50%,-50%)';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;}}
1420
+ (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';});
1249
1421
  var cb=$('lqcore');if(cb&&cb.style){cb.style.transform='translate('+Math.round(cx)+'px,'+(rest?Math.round(core.y+core.cr+18):56)+'px) translateX(-50%)';var ct=rest?(LQ.coreText||'All handled'):talk||pool?'':(LQ.coreText||'');cb.textContent=ct;cb.style.opacity=ct?'1':'0';cb.style.pointerEvents=ct?'auto':'none';}}
1250
1422
  // The liquid: a metaball surface (Σ r²/d² = 1) shaded as chrome, on the GPU.
1251
1423
  var LQ_FS=['precision highp float;',
1252
1424
  'uniform vec2 uRes;uniform float uT;uniform float uDpr;uniform vec3 uB[16];uniform vec3 uRip;uniform float uLight;uniform float uContrast;uniform vec4 uAccent;uniform float uExposure;',
1253
- 'float field(vec2 p){float f=0.0;for(int i=0;i<16;i++){vec3 b=uB[i];float r=b.z*uDpr;vec2 d=p-b.xy*uRes;f+=r*r/(dot(d,d)+1.0);}vec2 q=p/uDpr;return f*(1.0+0.045*sin(q.x*0.011+uT*0.7)*cos(q.y*0.009-uT*0.55)+0.025*sin(q.x*0.031-q.y*0.027+uT*1.3));}',
1425
+ 'float field(vec2 p){float f=0.0;for(int i=0;i<16;i++){vec3 b=uB[i];float r=b.z*uDpr;vec2 d=p-b.xy*uRes;f+=r*r/(dot(d,d)+1.0);}vec2 q=p/uDpr;return f*(1.0+0.025*sin(q.x*0.011+uT*0.7)*cos(q.y*0.009-uT*0.55)+0.012*sin(q.x*0.031-q.y*0.027+uT*1.3));}',
1254
1426
  'float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}',
1255
1427
  'vec3 env(vec3 r){float y=r.y;vec3 c=mix(mix(vec3(0.02,0.022,0.03),vec3(0.36,0.38,0.43),uLight),vec3(0.82,0.84,0.90),smoothstep(-0.35,0.55,y));c+=vec3(1.0)*pow(max(0.0,1.0-abs(y-0.18+0.04*sin(uT*0.3))*5.0),4.0)*1.1;c+=vec3(0.75,0.82,1.0)*pow(max(0.0,r.x),10.0)*0.9;c+=vec3(1.0,0.93,0.86)*pow(max(0.0,-r.x),14.0)*0.5;c*=0.92+0.08*sin(r.x*4.0+r.y*3.0+uT*0.4);return c;}',
1256
1428
  'void main(){vec2 p=vec2(gl_FragCoord.x,uRes.y-gl_FragCoord.y);float f=field(p);float e=2.0*uDpr;',
@@ -1281,10 +1453,12 @@ function lqDraw(G){var gl=G.gl,c=G.c,S=lqSize(),d=Math.min(window.devicePixelRat
1281
1453
  if(c.width!==W||c.height!==H){c.width=W;c.height=H;gl.viewport(0,0,W,H);}
1282
1454
  var now=(window.performance&&performance.now)?performance.now():Date.now(),t=(now-G.t0)/1000,th=LQ.theme,still=th.still,o=G.out;LQ.t=t;
1283
1455
  for(var i=0;i<48;i++)o[i]=0;
1284
- var put=function(k,x,y,r){if(k>15)return;var w=still?0:1;o[k*3]=x/S.w+Math.sin(t*1.3+k*1.7)*0.004*w;o[k*3+1]=y/S.h+Math.cos(t*1.1+k*2.3)*0.005*w;o[k*3+2]=Math.max(0,r)*(1+0.035*Math.sin(t*2.0+k)*w);};
1456
+ var put=function(k,x,y,r){if(k>15)return;var w=still?0:(k===0?1:0.3);o[k*3]=x/S.w+Math.sin(t*1.3+k*1.7)*0.004*w;o[k*3+1]=y/S.h+Math.cos(t*1.1+k*2.3)*0.005*w;o[k*3+2]=Math.max(0,r)*(1+0.035*Math.sin(t*2.0+k)*w);};
1285
1457
  put(0,LQ.core.x,LQ.core.y,LQ.core.cr);var k=1,tend=[];
1286
1458
  LQ.drops.forEach(function(dr){put(k++,dr.x,dr.y,dr.cr);if(dr.tendril&&LQ.mode==='aware'&&!LQ.talking)tend.push(dr);});
1287
- tend.forEach(function(dr){var dx=dr.x-LQ.core.x,dy=dr.y-LQ.core.y,len=Math.sqrt(dx*dx+dy*dy)||1,gap=len-LQ.core.cr-dr.cr;if(k<15&&gap>8){var at=LQ.core.cr+gap/2;put(k++,LQ.core.x+dx/len*at,LQ.core.y+dy/len*at,Math.min(20*S.s,gap*0.45));}});
1459
+ // a tendril is a chain of blobs close enough to always bridge (spaced at under 2.8r),
1460
+ // never one blob floating in the gap: drawn whole or not at all
1461
+ tend.forEach(function(dr){var dx=dr.x-LQ.core.x,dy=dr.y-LQ.core.y,len=Math.sqrt(dx*dx+dy*dy)||1,gap=len-LQ.core.cr-dr.cr;if(gap<=8)return;var n=Math.max(1,Math.min(3,Math.ceil(gap/40))),sp=gap/(n+1),rr=sp/2;if(k+n>15)return;for(var m=1;m<=n;m++){var at=LQ.core.cr+sp*m;put(k++,LQ.core.x+dx/len*at,LQ.core.y+dy/len*at,rr);}});
1288
1462
  var P=LQ.pointer;if(P&&LQ.mode==='aware'&&!LQ.talking){var best=null,bd=1e9;[LQ.core].concat(LQ.drops).forEach(function(g){if(g.cr<18)return;var dd=Math.hypot(P[0]-g.x,P[1]-g.y)-g.cr;if(dd<bd){bd=dd;best=g;}});
1289
1463
  if(best&&bd<230){var dx=P[0]-best.x,dy=P[1]-best.y,dl=Math.hypot(dx,dy)||1,reach=Math.min(dl,best.cr+70);put(15,best.x+dx/dl*reach,best.y+dy/dl*reach,8+20*(1-Math.max(0,bd)/230));}}
1290
1464
  var U=G.U;gl.uniform2f(U.uRes,W,H);gl.uniform1f(U.uT,t);gl.uniform1f(U.uDpr,d);gl.uniform3fv(U.uB,o);gl.uniform3f(U.uRip,LQ.ripple[0],LQ.ripple[1],still?-10:LQ.ripple[2]);
@@ -1292,16 +1466,18 @@ gl.uniform1f(U.uLight,th.light?1:0);gl.uniform1f(U.uContrast,th.contrast?1:0);va
1292
1466
  gl.drawArrays(gl.TRIANGLES,0,3);}
1293
1467
  function lqInit(){var bd=document.body;if(!bd||!bd.classList)return;lqTheme();bd.classList.add('lq-liquid');
1294
1468
  ['(prefers-color-scheme: light)','(prefers-contrast: more)','(forced-colors: active)','(prefers-reduced-transparency: reduce)','(prefers-reduced-motion: reduce)'].forEach(function(q){try{var mq=window.matchMedia&&window.matchMedia(q);if(mq&&mq.addEventListener)mq.addEventListener('change',lqTheme);else if(mq&&mq.addListener)mq.addListener(lqTheme);}catch(e){}});
1295
- var L=$('liquid');if(L&&L.addEventListener){L.addEventListener('pointermove',function(ev){LQ.pointer=[ev.clientX,ev.clientY];if(ev.pointerType==='touch')LQ.touch=true;lqAct();});L.addEventListener('pointerdown',function(ev){if(ev.pointerType==='touch')LQ.touch=true;lqAct();});}
1469
+ var L=$('liquid');if(L&&L.addEventListener){L.addEventListener('click',function(ev){var t=ev&&ev.target;if(t&&t.closest&&t.closest('button,input,form,.lqd'))return;var x=ev.clientX,y=ev.clientY,hit=null;LQ.drops.forEach(function(d){if(!hit&&Math.hypot(x-d.x,y-d.y)<=d.cr+8)hit=d;});if(hit){if(hit.kind==='more')lqOpen(hit,ev);else lqTag(hit.id);}else if(LQ.openTag)lqTag(LQ.openTag);});L.addEventListener('pointermove',function(ev){LQ.pointer=[ev.clientX,ev.clientY];if(ev.pointerType==='touch')LQ.touch=true;lqAct();});L.addEventListener('pointerdown',function(ev){if(ev.pointerType==='touch')LQ.touch=true;lqAct();});}
1296
1470
  var f=$('lqform');if(f&&f.addEventListener)f.addEventListener('submit',function(ev){if(ev&&ev.preventDefault)ev.preventDefault();lqSay();});
1297
- var ia=$('lqask');if(ia&&ia.addEventListener){ia.addEventListener('focus',function(){lqTalkMode(true);lqAct();});ia.addEventListener('blur',function(){if(!ia.value&&!LQ.talk.length)lqTalkMode(false);});ia.addEventListener('keydown',function(ev){if(ev&&ev.key==='Escape'){if(LQ.mode==='pool'){lqSink();return;}ia.value='';LQ.talk=[];lqTalkShow(false);lqTalkMode(false);if(ia.blur)ia.blur();}});}
1471
+ var ia=$('lqask');if(ia&&ia.addEventListener){ia.addEventListener('focus',function(){lqTalkMode(true);lqAct();});ia.addEventListener('blur',function(){if(!ia.value&&!LQ.talk.length)lqTalkMode(false);});ia.addEventListener('keydown',function(ev){if(ev&&ev.key==='Escape'){if(LQ.mode==='pool'){lqSink();return;}if(LQ.scene==='work'&&!ia.value){lqHome();return;}ia.value='';LQ.talk=[];lqTalkShow(false);lqTalkMode(false);if(ia.blur)ia.blur();}});}
1298
1472
  var co=$('lqcore');if(co&&co.addEventListener)co.addEventListener('click',function(){var i=$('lqask');if(i&&i.focus)i.focus();});
1299
1473
  var sk=$('lqsink');if(sk&&sk.addEventListener)sk.addEventListener('click',lqSink);
1474
+ var bk=$('lqback');if(bk&&bk.addEventListener)bk.addEventListener('click',lqHome);
1475
+ var gb=$('lqgo');if(gb&&gb.addEventListener)gb.addEventListener('click',lqGoWork);
1300
1476
  var mn=document.querySelector('main');if(mn&&mn.addEventListener)mn.addEventListener('keydown',function(ev){if(ev&&ev.key==='Escape')lqSink();});
1301
1477
  lqLoad(true);
1302
1478
  // the only clocks: rest when left alone, fresh data now and then, the hour (night)
1303
1479
  setInterval(function(){if(LQ.mode==='aware'&&!LQ.talking&&Date.now()-LQ.lastAct>LQ_REST)LQ.mode='rest';if(LQ.mode!=='pool')lqLoad(false);var h=new Date().getHours(),n=h>=22||h<6;if(n!==LQ.theme.night)lqTheme();},30000);
1304
- var G=lqGL();if(typeof window.requestAnimationFrame!=='function')return;var raf=function(f){return window.requestAnimationFrame(f);};
1480
+ var G=lqGL();if(G){try{lqStep();lqDraw(G);}catch(e){}}if(typeof window.requestAnimationFrame!=='function')return;var raf=function(f){return window.requestAnimationFrame(f);};
1305
1481
  function frame(){if(!document.hidden){LQ.frame++;if(LQ.mode!=='pool'||LQ.frame%3===0){lqStep();if(G)lqDraw(G);}}raf(frame);}
1306
1482
  raf(frame);}
1307
1483
 
package/watch.mjs CHANGED
@@ -301,7 +301,8 @@ async function boardChat(id, question, { hours = 72, now = Date.now(), ask = wri
301
301
  const answer = r.reply;
302
302
  // read again: a check may have saved the file while the model answered
303
303
  const d2 = loadWatch(), w2 = d2.watches.find((x) => x.id === id); if (!w2) return { answer, chat: [] };
304
- w2.chat = [...(w2.chat || []), { role: "user", text: question, ts: now }, { role: "ai", text: answer, ts: now }].slice(-TALK_KEEP);
304
+ const steps = [...(peeks.length ? [`looked up ${peeks.length} link${peeks.length > 1 ? "s" : ""}`] : []), ...(r.steps || [])];
305
+ w2.chat = [...(w2.chat || []), { role: "user", text: question, ts: now }, { role: "ai", text: answer, ts: now, ...(steps.length ? { steps } : {}) }].slice(-TALK_KEEP);
305
306
  saveWatch(d2);
306
307
  return { answer, chat: w2.chat, ...(peeks.length ? { links: peeks } : {}), ...(r.did ? { did: r.did } : {}) };
307
308
  }
package/work.mjs ADDED
@@ -0,0 +1,113 @@
1
+ // symbiot — Work: what an agent is doing, as it does it. Claude Code runs with
2
+ // --output-format stream-json (agents.mjs withStream), so every step lands in the
3
+ // run's .symbiot/agent.log as it happens: each tool it calls and what came back,
4
+ // its own to-do list (TaskCreate/TaskUpdate, or the older TodoWrite) with the
5
+ // "Reading x.txt" line for what's in progress, what it says between steps, and at
6
+ // the end what it answered, what it cost, how many turns and tokens. parseRun
7
+ // turns that into something to draw: steps, to-dos, tests, files, a pace over
8
+ // time. Another agent (Codex, Aider, Gemini) writes plain text: its last lines
9
+ // come through as what it said. Pure: text in, data out.
10
+
11
+ const MAX_STEPS = 60;
12
+ const short = (s, n = 80) => { s = String(s ?? "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n - 1) + "…" : s; };
13
+ const base = (p) => String(p || "").split("/").filter(Boolean).slice(-2).join("/");
14
+
15
+ // One tool call, as a person would say it: { kind, verb, target }.
16
+ function describe(name, input = {}) {
17
+ const n = String(name || "");
18
+ if (n === "Read") return { kind: "read", verb: "Read", target: base(input.file_path) };
19
+ if (n === "Edit" || n === "MultiEdit") return { kind: "edit", verb: "Edited", target: base(input.file_path) };
20
+ if (n === "Write") return { kind: "edit", verb: "Wrote", target: base(input.file_path) };
21
+ if (n === "NotebookEdit") return { kind: "edit", verb: "Edited", target: base(input.notebook_path) };
22
+ if (n === "Bash") return { kind: "run", verb: "Ran", target: short(input.description || input.command, 70) };
23
+ if (n === "Grep") return { kind: "search", verb: "Searched for", target: short(input.pattern, 50) };
24
+ if (n === "Glob") return { kind: "search", verb: "Listed", target: short(input.pattern, 50) };
25
+ if (n === "WebFetch") return { kind: "web", verb: "Read", target: short(input.url, 60) };
26
+ if (n === "WebSearch") return { kind: "web", verb: "Searched the web for", target: short(input.query, 60) };
27
+ if (n === "Task" || n === "Agent") return { kind: "agent", verb: "Asked a helper to", target: short(input.description || input.prompt, 60) };
28
+ if (/^mcp__/.test(n)) { const parts = n.split("__"); return { kind: "connector", verb: "Used", target: short((parts[1] || "").replace(/^claude_ai_/, "").replace(/_/g, " ") + (parts[2] ? " · " + parts[2].replace(/_/g, " ") : ""), 60) }; }
29
+ return { kind: "tool", verb: "Used", target: n };
30
+ }
31
+ // Tools that are bookkeeping, not work: the to-do list (shown as to-dos) and
32
+ // looking up its own tools.
33
+ const QUIET = new Set(["TaskCreate", "TaskUpdate", "TaskList", "TaskGet", "TodoWrite", "ToolSearch", "ExitPlanMode"]);
34
+
35
+ // A test run's tally from its output: { passed, failed } or null.
36
+ function testsIn(text) {
37
+ const t = String(text || ""); let passed = 0, failed = 0, seen = false;
38
+ for (const m of t.matchAll(/(\d+)\s+passed,\s+(\d+)\s+failed/g)) { passed += +m[1]; failed += +m[2]; seen = true; }
39
+ if (!seen) { const p = t.match(/(\d+)\s+(?:tests?\s+)?passed/), f = t.match(/(\d+)\s+(?:tests?\s+)?failed/); if (p || f) { passed = p ? +p[1] : 0; failed = f ? +f[1] : 0; seen = true; } }
40
+ return seen ? { passed, failed } : null;
41
+ }
42
+ const resultText = (c) => (typeof c === "string" ? c : Array.isArray(c) ? c.map((x) => (x && typeof x.text === "string" ? x.text : "")).join("\n") : "");
43
+
44
+ // The text of one run's log (everything after its "=== name time ===" header and
45
+ // command line) → { stream, model, steps, todos, doing, said, final, cost, turns,
46
+ // tokens, tests, files, pace, errors }.
47
+ function parseRun(text) {
48
+ const lines = String(text || "").split("\n");
49
+ const events = []; let plain = [];
50
+ for (const l of lines) { if (l.startsWith("{")) { try { events.push(JSON.parse(l)); continue; } catch {} } if (l.trim()) plain.push(l); }
51
+ const out = { stream: events.length > 0, model: "", steps: [], todos: [], doing: "", said: [], final: "", cost: null, turns: null, tokens: null, tests: null, files: {}, pace: [], errors: 0 };
52
+ if (!out.stream) { out.said = plain.slice(-12).map((l) => short(l, 160)); out.final = plain.slice(-40).join("\n").trim(); return out; }
53
+ const byId = new Map(), tasks = new Map(); let thinking = false;
54
+ for (const e of events) {
55
+ const at = e.timestamp ? Date.parse(e.timestamp) : null;
56
+ if (e.type === "system" && e.subtype === "init") { out.model = String(e.model || ""); continue; }
57
+ if (e.type === "assistant" && e.message && Array.isArray(e.message.content)) {
58
+ for (const b of e.message.content) {
59
+ if (b.type === "thinking") { thinking = true; continue; }
60
+ if (b.type === "text" && b.text && b.text.trim()) { out.said.push(short(b.text, 220)); thinking = false; continue; }
61
+ if (b.type !== "tool_use") continue;
62
+ thinking = false;
63
+ const inp = b.input || {};
64
+ if (b.name === "TodoWrite" && Array.isArray(inp.todos)) { tasks.clear(); inp.todos.forEach((t, i) => tasks.set(String(i + 1), { text: short(t.content, 120), status: t.status || "pending", active: short(t.activeForm || t.content, 90) })); continue; }
65
+ if (b.name === "TaskCreate") { byId.set(b.id, { create: { text: short(inp.subject || inp.description, 120), active: short(inp.activeForm || inp.subject, 90) } }); continue; }
66
+ if (b.name === "TaskUpdate") { const t = tasks.get(String(inp.taskId)); if (t && inp.status) t.status = inp.status; if (t && inp.subject) t.text = short(inp.subject, 120); continue; }
67
+ if (QUIET.has(b.name)) continue;
68
+ const d = describe(b.name, inp), step = { ...d, at, status: "running" };
69
+ if (d.kind === "edit" && d.target) out.files[d.target] = (out.files[d.target] || 0) + 1;
70
+ byId.set(b.id, step); out.steps.push(step);
71
+ }
72
+ continue;
73
+ }
74
+ if (e.type === "user" && e.message && Array.isArray(e.message.content)) {
75
+ for (const b of e.message.content) {
76
+ if (b.type !== "tool_result") continue;
77
+ const s = byId.get(b.tool_use_id), txt = resultText(b.content);
78
+ if (s && s.create) { const m = txt.match(/Task #(\w+)/); tasks.set(m ? m[1] : String(tasks.size + 1), { text: s.create.text, status: "pending", active: s.create.active }); continue; }
79
+ if (!s) continue;
80
+ s.status = b.is_error ? "error" : "done"; if (b.is_error) { out.errors++; s.note = short(txt, 120); }
81
+ if (at && s.at) s.ms = at - s.at;
82
+ if (s.kind === "run") { const tt = testsIn(txt); if (tt) { s.tests = tt; out.tests = tt; } }
83
+ }
84
+ continue;
85
+ }
86
+ if (e.type === "result") {
87
+ out.final = String(e.result || "").trim();
88
+ out.cost = typeof e.total_cost_usd === "number" ? e.total_cost_usd : null;
89
+ out.turns = typeof e.num_turns === "number" ? e.num_turns : null;
90
+ const u = e.usage || {}; out.tokens = (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) || null;
91
+ if (e.is_error) out.errors++;
92
+ }
93
+ }
94
+ out.todos = [...tasks.values()];
95
+ const now = out.todos.find((t) => t.status === "in_progress"), last = out.steps[out.steps.length - 1];
96
+ out.doing = out.final ? "" : now ? now.active : last && last.status === "running" ? `${last.verb} ${last.target}`.trim() : thinking ? "Thinking" : "";
97
+ // pace: steps per minute over the run, up to 24 buckets (a sparkline)
98
+ const times = out.steps.map((s) => s.at).filter(Boolean);
99
+ if (times.length > 1) { const t0 = times[0], span = Math.max(times[times.length - 1] - t0, 60000), n = Math.min(24, Math.max(6, Math.ceil(span / 60000))), w = span / n; out.pace = new Array(n).fill(0); for (const t of times) out.pace[Math.min(n - 1, Math.floor((t - t0) / w))]++; }
100
+ out.steps = out.steps.slice(-MAX_STEPS).map(({ at, ...s }) => ({ ...s, ...(at ? { at } : {}) }));
101
+ out.said = out.said.slice(-6);
102
+ return out;
103
+ }
104
+
105
+ // The newest run's part of an agent.log (after its header and command line).
106
+ function lastRunText(log) {
107
+ const s = String(log || ""), at = s.lastIndexOf("\n=== ");
108
+ return at < 0 ? s : s.slice(at).split("\n").slice(3).join("\n");
109
+ }
110
+ // What the agent said last: its final answer, from either kind of log.
111
+ function finalOf(log) { return parseRun(lastRunText(log)).final; }
112
+
113
+ export { parseRun, lastRunText, finalOf, describe, testsIn };
package/writeups.mjs CHANGED
@@ -161,7 +161,7 @@ async function taskChat(id, question) {
161
161
  // Re-read: other requests may have changed tasks.json while the model ran.
162
162
  const t = loadTasks(); const cur = t.find((x) => x.id === id); const now = Date.now();
163
163
  if (!cur) return { answer, chat: [] };
164
- cur.chat = [...(cur.chat || []), { role: "user", text: question, ts: now }, { role: "ai", text: answer, ts: now }].slice(-CHAT_KEEP);
164
+ cur.chat = [...(cur.chat || []), { role: "user", text: question, ts: now }, { role: "ai", text: answer, ts: now, ...(r.steps && r.steps.length ? { steps: r.steps } : {}) }].slice(-CHAT_KEEP);
165
165
  saveTasks(t);
166
166
  return { answer, chat: cur.chat, ...(r.did ? { did: r.did } : {}) };
167
167
  }