symbiot 0.45.0 → 0.46.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,14 @@
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.46.0 — 2026-10-06
6
+
7
+ - **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.
8
+ - **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.
9
+ - **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.
10
+ - **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.
11
+ - **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).
12
+
5
13
  ## 0.45.0 — 2026-10-06
6
14
 
7
15
  - **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
@@ -68,7 +68,7 @@ async function homeAsk(question, { ask, now = Date.now(), state } = {}) {
68
68
  agent: (req, known, repo) => (repo ? actIn(req, repo, { map, known, title: "Home" }) : actNow(req, { title: "Home", known })),
69
69
  task: (text, repo) => taskIn(text, repo, { map }),
70
70
  } });
71
- return { answer: r.reply, ...(r.did ? { did: r.did } : {}) };
71
+ return { answer: r.reply, ...(r.did ? { did: r.did } : {}), steps: r.steps || [] };
72
72
  }
73
73
 
74
74
  export { homeState, homeContext, homeAsk };
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.46.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/ui.mjs CHANGED
@@ -60,6 +60,67 @@ body.lq-liquid footer{position:fixed;right:12px;top:38px;border:0;padding:0;z-in
60
60
  body.lq-pooled #lqtalk,body.lq-pooled #lqmore,body.lq-pooled #lqcore{display:none}
61
61
  body.lq-talking .lqd{opacity:.55}
62
62
  @media (prefers-reduced-motion: reduce){.lqd,#lqcore{transition:none}}
63
+ /* ---- the Symbiot look on every part: glass over the liquid, chrome, a silver edge ---- */
64
+ 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)}
65
+ 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}
66
+ body.lq-liquid.lq-solid.lq-pooled main{backdrop-filter:none;background:var(--ink)}
67
+ .lqedge{position:fixed;top:64px;left:max(12px,4vw);right:max(12px,4vw);bottom:88px;z-index:6;border-radius:24px;pointer-events:none;padding:1px;background:linear-gradient(115deg,rgba(255,255,255,.0) 0%,rgba(255,255,255,.75) 18%,rgba(140,150,166,.3) 32%,rgba(255,255,255,.0) 48%,rgba(230,236,244,.6) 66%,rgba(255,255,255,0) 82%);background-size:260% 260%;-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask-composite:exclude;animation:lqsheen 9s linear infinite;display:none}
68
+ body.lq-liquid.lq-pooled .lqedge{display:block}
69
+ @keyframes lqsheen{0%{background-position:0% 50%}100%{background-position:260% 50%}}
70
+ 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)}
71
+ 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)}
72
+ 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)}
73
+ 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)}
74
+ body.lq-liquid.lq-light button.ghost{background:rgba(255,255,255,.7);border-color:rgba(20,30,45,.18)}
75
+ 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)}
76
+ 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)}
77
+ 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)}
78
+ body.lq-liquid .tgroup{color:#C9D1DC;letter-spacing:.16em}
79
+ body.lq-liquid.lq-light .tgroup{color:#3B4656}
80
+ body.lq-liquid h2,body.lq-liquid h3{letter-spacing:-.01em}
81
+ body.lq-liquid .fchip.on{background:linear-gradient(180deg,#FFFFFF,#C9D1DC);color:#06080B;border-color:#FFFFFF}
82
+ body.lq-contrast.lq-liquid .task,body.lq-contrast.lq-liquid .agent{border:2px solid currentColor}
83
+ /* the live work of an agent: what it's doing, its to-dos, its steps, its files, its pace */
84
+ .wk{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(260px,100%),1fr));gap:12px;margin-top:10px}
85
+ .wkbox{border-radius:14px;padding:10px 12px;background:rgba(255,255,255,.035);border:1px solid rgba(220,228,240,.12)}
86
+ body.lq-light .wkbox{background:#F6F8FB;border-color:rgba(20,30,45,.1)}
87
+ .wkh{font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--faint);margin-bottom:6px;display:flex;gap:6px;align-items:center}
88
+ .wkstats{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
89
+ .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}
90
+ .wkstat b{color:var(--bone)}
91
+ .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}
92
+ body.lq-light .wkdoing{background-image:linear-gradient(90deg,#4A5565 0%,#0F1720 40%,#4A5565 60%,#4A5565 100%)}
93
+ @keyframes lqshim{0%{background-position:120% 0}100%{background-position:-120% 0}}
94
+ .wktodo{display:flex;gap:8px;align-items:flex-start;font-size:12.5px;line-height:1.4;padding:3px 0}
95
+ .wktodo i{flex:none;width:12px;height:12px;margin-top:2px;border-radius:50%;border:1.5px solid var(--faint)}
96
+ .wktodo.in_progress i{border-color:var(--bone);background:conic-gradient(var(--bone) 0 50%,transparent 50%);animation:lqspin 1.4s linear infinite}
97
+ .wktodo.completed i{border-color:var(--green);background:var(--green)}
98
+ .wktodo.completed span{color:var(--faint);text-decoration:line-through}
99
+ @keyframes lqspin{to{transform:rotate(360deg)}}
100
+ .wkstep{display:flex;gap:8px;align-items:center;font-size:12.5px;padding:3px 0;min-width:0}
101
+ .wkstep svg{flex:none;opacity:.85}
102
+ .wkstep .wt{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
103
+ .wkstep .wt b{font-weight:600;color:var(--bone)}
104
+ .wkstep .wm{flex:none;font-size:11px;color:var(--faint)}
105
+ .wkstep.error .wt{color:#FF8A75}.wkstep.running .wt{color:var(--bone)}
106
+ .wktest{font-size:11px;padding:1px 7px;border-radius:999px;background:rgba(61,220,151,.14);color:var(--green);white-space:nowrap}
107
+ .wktest.bad{background:rgba(255,138,117,.16);color:#FF8A75}
108
+ .wkfile{display:flex;gap:8px;align-items:center;font-size:12px;padding:2px 0}
109
+ .wkfile .fn{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,Menlo,Consolas,monospace}
110
+ .wkfile .fb{flex:1;height:6px;border-radius:3px;background:rgba(220,228,240,.12);overflow:hidden}
111
+ .wkfile .fb i{display:block;height:100%;border-radius:3px;background:linear-gradient(90deg,#8E97A6,#FFFFFF)}
112
+ body.lq-light .wkfile .fb i{background:linear-gradient(90deg,#6F7887,#0F1720)}
113
+ .wkfinal{margin-top:10px;font-size:13px;line-height:1.55;white-space:pre-wrap}
114
+ .wkraw summary{cursor:pointer;font-size:12px;color:var(--faint);margin-top:10px}
115
+ .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)}
116
+ .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%)}
117
+ @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)}}
118
+ .msg .steps,.lqmsg .steps{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}
119
+ .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}
120
+ .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}
121
+ body.lq-light .thinking{background-image:linear-gradient(90deg,#4A5565 0%,#0F1720 40%,#4A5565 60%,#4A5565 100%)}
122
+ @media (prefers-reduced-motion: reduce){.lqedge,.wkdoing,.thinking,.orb.run,.wktodo.in_progress i{animation:none}}
123
+
63
124
 
64
125
  *{box-sizing:border-box}html,body{margin:0;height:100%}
65
126
  body{background:var(--ink);color:var(--text);font-family:var(--sans);font-size:14px;display:flex;flex-direction:column}
@@ -85,9 +146,14 @@ a{color:var(--green);cursor:pointer}.hidden{display:none}
85
146
  .note{font-size:12px;margin-top:10px}.ok{color:var(--green)}.err{color:var(--amber)}
86
147
  .updatebar{display:none;align-items:center;gap:12px;padding:9px 16px;font-size:13px;font-weight:600;background:var(--green);color:var(--ink)}
87
148
  .updatebar.show{display:flex}
149
+ 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)}
150
+ body.lq-liquid .updatebar.show.reconnect{background:var(--amber)}
151
+ body.lq-liquid #appbar.updatebar.show{top:58px}
88
152
  .updatebar.reconnect{background:var(--amber)}
89
153
  .whatsnew{display:none;padding:10px 16px;font-size:13px;background:var(--ink2);border-bottom:1px solid var(--line);max-height:40vh;overflow:auto}
90
154
  .whatsnew.show{display:block}
155
+ 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)}
156
+ body.lq-liquid.lq-light .whatsnew.show{background:rgba(255,255,255,.9);border-color:rgba(20,30,45,.15)}
91
157
  .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
158
  .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
159
  footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
@@ -230,7 +296,7 @@ label.check input{width:auto}
230
296
  <button class="tab" data-tab="settings">Settings</button>
231
297
  </div>
232
298
  <main>
233
- <div id="lqsinkrow"><button class="ghost" type="button" id="lqsink" title="close it: it sinks back into the liquid (Esc)">Sink back</button></div>
299
+ <div class="lqedge" aria-hidden="true"></div><div id="lqsinkrow"><button class="ghost" type="button" id="lqsink" title="close it: it sinks back into the liquid (Esc)">Sink back</button></div>
234
300
  <section id="panel-map">
235
301
  <div class="profile muted" id="profile">Mapping your work&hellip;</div>
236
302
  <div class="maprow">
@@ -439,10 +505,10 @@ edges.forEach(function(e){var a=idx[e.source],b=idx[e.target];if(!a||!b)return;v
439
505
  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
506
  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
507
  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>";
508
+ 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)+")'>";
509
+ 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+"'/>";});
510
+ 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"):"");
511
+ 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
512
  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
513
  document.getElementById("graph").innerHTML=s+"</g>";}
448
514
  function profileLine(g){var repos=g.nodes.filter(function(n){return n.type==="repo";});var base=g.stats.base||"your home folder";
@@ -498,7 +564,8 @@ else if(rm)rm.addEventListener('click',function(){api('/api/tasks/remove',{id:id
498
564
  // connected model, grounded in the task's repo (and its pending changes).
499
565
  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
566
  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("");}
567
+ function stepsHtml(st){return st&&st.length?"<div class='steps'>"+st.map(function(x){return "<span>"+esc(x)+"</span>";}).join('')+"</div>":"";}
568
+ 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
569
  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
570
  function wireAsk(row,id){var b=row.querySelector('.ask');if(b)b.addEventListener('click',function(){toggleChat(row,id);});}
504
571
  function toggleChat(row,id){var btn=row.querySelector('.ask');var nx=row.nextElementSibling;
@@ -646,6 +713,32 @@ var LANEWORD={started:'working on it',held:'queued: that lane is busy',done:'don
646
713
  function loadLanes(){api('/api/lanes').then(function(d){var el=document.getElementById('laneslist');if(!el||!d)return;var hs=d.handoffs||[];
647
714
  el.innerHTML=hs.length?"<div class='tgroup'>Handovers <span class='tcount'>"+hs.length+"</span></div>"+hs.slice(0,12).map(function(h){
648
715
  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(''):'';});}
716
+
717
+ // ---- an agent's work, live (work.mjs parses its stream) ------------------------------
718
+ 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'/>"};
719
+ 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>";}
720
+ 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';}
721
+ 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>";}
722
+ 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>";}
723
+ function workHtml(a){var w=a.work,pg=a.progress,run=a.status==='running',h='';
724
+ 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;}
725
+ 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>");
726
+ 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>");
727
+ 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>");
728
+ h+="<div class='wkstats'>"+stats.join('')+"</div>";
729
+ if(run&&w.doing)h+="<div class='wkdoing'>"+esc(w.doing)+"&hellip;</div>";
730
+ var boxes=[];
731
+ 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>";
732
+ (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>";});
733
+ 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>");}
734
+ 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>";
735
+ 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>";});
736
+ 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>");}
737
+ 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>";
738
+ 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>");}
739
+ if(boxes.length)h+="<div class='wk'>"+boxes.join('')+"</div>";
740
+ if(!run&&w.final)h+="<div class='wkfinal'>"+esc(w.final)+"</div>";
741
+ return h;}
649
742
  function loadAgents(){loadLanes();api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
650
743
  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
744
  var anyRunning=list.some(function(a){return a.status==='running';});
@@ -660,8 +753,8 @@ if(nQs(a))st+=" &middot; <span style='color:var(--amber)'>needs your answers</sp
660
753
  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
754
  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
755
  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>";
756
+ var b="<div class='dh'><span class='orb "+cls+"'></span><span class='dn'>"+esc(a.name)+"</span><span class='dd'>"+st+"</span></div>";
757
+ b+=workHtml(a);
665
758
  var ch=a.changed;
666
759
  if(ch&&(ch.dirty||ch.stat||(ch.commits&&ch.commits.length))){
667
760
  var parts=[];
@@ -673,7 +766,7 @@ if(ch&&(ch.dirty||ch.stat||(ch.commits&&ch.commits.length))){
673
766
  b+="<div class='changed muted'>No file changes detected (the agent may have only planned or asked).</div>";
674
767
  }
675
768
  b+=waitHtml(a)+askHtml(a);
676
- b+="<pre class='alogout'>"+esc((a.tail&&a.tail.trim())||'(waiting for output…)')+"</pre>";
769
+ 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
770
  return "<div class='agent'>"+b+"</div>";}).join("");
678
771
  el.querySelectorAll('.alogout').forEach(function(p){p.scrollTop=p.scrollHeight;});
679
772
  restoreDrafts(el);wireAsks(el);wireWaits(el);
@@ -1191,8 +1284,14 @@ var vx=Math.max(0.5,Math.min(S.s*1.3,(S.w/2-90)/400)),vy=Math.max(0.35,Math.min(
1191
1284
  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
1285
  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
1286
  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;});
1287
+ // Solve it. If this screen can't hold them all clear of each other (lqRelax), the
1288
+ // least likely part goes under "more" (Hick: fewer, not cramped) and it solves
1289
+ // again. What only you can do and your feeds always stay out.
1290
+ var base=list.slice(),cur=null,tries=0;base.forEach(function(d){d.r0=d.r;d.tx0=d.tx;d.ty0=d.ty;});
1291
+ 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});
1292
+ 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;
1293
+ 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);}
1294
+ list=cur;var old={};LQ.drops.forEach(function(d){old[d.id]=d;});
1196
1295
  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
1296
  var n=(h.you||[]).length,w=h.working||0;
1198
1297
  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,13 +1301,26 @@ lqLabels();}
1202
1301
  // Force-directed layout, solved once per change: push overlapping targets apart
1203
1302
  // (each droplet's circle plus its label below or above), and off the core, within
1204
1303
  // 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;}}
1304
+ // The one rule for how far apart two droplets sit: past where their metal would
1305
+ // bridge (r²/d² fields sum to 1 midway at d = 2·√(r1²+r2²)), with a margin so the
1306
+ // wobble can't flicker a bridge open and shut, and room for their labels when
1307
+ // they share a column. Used once, when the layout is solved; nothing fights it live.
1308
+ function lqSep(a,b,dx){return 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);}
1309
+ // Solve the layout: push targets apart until every pair keeps lqSep (and clear
1310
+ // of the core), within the screen. If they can't all fit, every droplet gives up
1311
+ // a little of its size (the liquid is conserved, not crowded) and it solves again.
1312
+ function lqRelax(L,S,cx,cy,coreR){var n=L.length;
1313
+ for(var pass=0;pass<6;pass++){
1314
+ for(var it=0;it<220;it++){var moved=0;for(var i=0;i<n;i++){var a=L[i];
1315
+ 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);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++;}}
1316
+ 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++;}
1317
+ 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;}
1318
+ 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)-1){bad=true;break;}}
1319
+ if(!bad)return true;L.forEach(function(x){x.r*=0.9;});coreR*=0.9;}return false;}
1209
1320
  function lqLabels(){var el=$('lqdrops');if(!el)return;
1210
1321
  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
1322
  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);});});
1323
+ 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
1324
  var c=$('lqcore');if(c)c.textContent=LQ.coreText||'';}
1213
1325
  function lqVia(ev){return LQ.talking?'talk':ev&&ev.pointerType==='touch'?'touch':ev&&ev.detail===0?'key':'click';}
1214
1326
  function lqUse(shape,ev,via){api('/api/adapt/use',{shape:shape,from:LQ.last,via:via||lqVia(ev)});LQ.last=shape;}
@@ -1222,13 +1334,13 @@ function lqPool(shape){tabPicked=true;setTab(shape);var b=document.body;if(b&&b.
1222
1334
  function lqSink(){var b=document.body;if(b&&b.classList)b.classList.remove('lq-pooled');LQ.mode='aware';lqAct();lqLoad(false);}
1223
1335
  function lqAct(){LQ.lastAct=Date.now();if(LQ.mode==='rest'){LQ.mode='aware';lqLoad(true);}}
1224
1336
  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;}
1337
+ 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
1338
  function lqSay(){var i=$('lqask');var q=((i&&i.value)||'').trim();if(!q)return;i.value='';lqAct();
1227
1339
  var low=q.toLowerCase().replace(/^(please |can you |could you )/,''),hit='';
1228
1340
  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
1341
  if(hit){lqTalkMode(true);lqUse(hit,null,'talk');lqPool(hit);return;}
1230
1342
  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);});}
1343
+ 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
1344
  // The physics: each droplet a critically damped spring to its place (no wobble,
1233
1345
  // no overshoot), pushed off its neighbours and the core where they'd overlap.
1234
1346
  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;
@@ -1240,8 +1352,6 @@ if(rest){tx=cx+Math.cos(i*1.3)*16;ty=cy+Math.sin(i*1.3)*16;tr=d.r*0.6;}
1240
1352
  else if(pool){tx=cx+(i-(N-1)/2)*40*S.s;ty=S.h-40;tr=13*S.s;}
1241
1353
  else if(talk){ty=S.h*0.12+(d.ty-S.h*0.12)*0.55;}
1242
1354
  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
1355
  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
1356
  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
1357
  d.cr+=(tr-d.cr)*(still?1:0.06);
@@ -1250,7 +1360,7 @@ var cb=$('lqcore');if(cb&&cb.style){cb.style.transform='translate('+Math.round(c
1250
1360
  // The liquid: a metaball surface (Σ r²/d² = 1) shaded as chrome, on the GPU.
1251
1361
  var LQ_FS=['precision highp float;',
1252
1362
  '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));}',
1363
+ '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
1364
  'float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}',
1255
1365
  '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
1366
  'void main(){vec2 p=vec2(gl_FragCoord.x,uRes.y-gl_FragCoord.y);float f=field(p);float e=2.0*uDpr;',
@@ -1281,10 +1391,12 @@ function lqDraw(G){var gl=G.gl,c=G.c,S=lqSize(),d=Math.min(window.devicePixelRat
1281
1391
  if(c.width!==W||c.height!==H){c.width=W;c.height=H;gl.viewport(0,0,W,H);}
1282
1392
  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
1393
  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);};
1394
+ 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
1395
  put(0,LQ.core.x,LQ.core.y,LQ.core.cr);var k=1,tend=[];
1286
1396
  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));}});
1397
+ // a tendril is a chain of blobs close enough to always bridge (spaced at under 2.8r),
1398
+ // never one blob floating in the gap: drawn whole or not at all
1399
+ 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
1400
  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
1401
  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
1402
  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]);
@@ -1301,7 +1413,7 @@ var mn=document.querySelector('main');if(mn&&mn.addEventListener)mn.addEventList
1301
1413
  lqLoad(true);
1302
1414
  // the only clocks: rest when left alone, fresh data now and then, the hour (night)
1303
1415
  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);};
1416
+ 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
1417
  function frame(){if(!document.hidden){LQ.frame++;if(LQ.mode!=='pool'||LQ.frame%3===0){lqStep();if(G)lqDraw(G);}}raf(frame);}
1306
1418
  raf(frame);}
1307
1419
 
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
  }