symbiot 0.57.2 → 0.57.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
4
4
 
5
+ ## 0.57.4
6
+
7
+ - **Home's layout holds with agents at work.** The agents at work used to sit among the section orbs. That pushed the headings off their own orbs ("Projects" over Todo, "Resources" over Workdesk), drew "Watching" on top of "Areas", scattered what you watch, and squeezed each agent's name into a narrow column ("Ste / ve"). Now they have a band of their own under "Needs you": a small orb each, the lane's name in full, and its step, what it's on and the time left under it. The sections keep their own places, each heading by its orbs. When the window is too small for that and Home falls back to its free layout, no heading is drawn on another.
8
+ - **No stray orb by the chat.** An orb reaching toward your pointer could hang in the gap as an orb of its own, with no label. It now stays a bulge on the orb's edge.
9
+ - **The Needs-you card no longer covers its orb.** It sits clear to the side, joined to the orb by a short liquid line.
10
+
11
+ ## 0.57.3
12
+
13
+ - **See how far along each agent is.** Home used to say only "Agents working: 2". Now each agent at work gets its own drop by the Workdesk, showing its lane, which step of its brief it's on ("step 2 of 4: look and feel, plus a zoned world") and what it's doing right now ("shooting the monsters, camp and gates"), taken from its latest actions. The same line shows on its lane under Tasks, on the Away screen, and in what Home's chat knows. Every brief now asks an agent to say "Step N: …" as it starts each numbered step, so the count stays right.
14
+ - **How long it has left.** Next to the step, a range like "5–15 min left". It comes from that lane's past runs of the same kind, counting only the ones that ran longer than this one has so far, so a run 20 minutes in isn't compared with one-minute replies. Tasks queued for the lane's next run add to it ("+2 queued: 30–60 min in all"). If a run is past nearly all of its lane's runs, it says "longer than its usual 10–25 min" rather than guessing. Symbiot also keeps how long each finished run took, and on which task, so the estimate gets better the more you use it.
15
+ - **A stalled run is flagged.** If an agent hasn't said or done anything for 20 minutes, its drop turns amber and says "no progress in 25 min". The same happens if its last five steps were all the same call ("repeating the same step"). The middle of Home counts them: "2 agents working (1 stalled)".
16
+
5
17
  ## 0.57.2
6
18
 
7
19
  - **No more "Show me your work" when you've already said.** On a big home folder the search for your projects could run out of time and come back empty, so Home asked where your repos were. Repos found before the cut-off are now kept, a short search keeps the repos the last one found, the folders you set in Settings count as your answer, and your main checkout stays a lane even when a worktree is fresher. Tool installs like `~/.nvm` and `~/.rustup` are no longer taken for projects.
package/agents.mjs CHANGED
@@ -8,7 +8,8 @@ import { fileURLToPath } from "node:url";
8
8
  import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync, readdirSync, statSync, renameSync } from "node:fs";
9
9
  import { randomBytes, createHash } from "node:crypto";
10
10
  import { CONFIG_DIR, loadConfig, saveConfig, loadTasks, TASK_MAX, clipWords, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
11
- import { parseRun, lastRunText, readRunLog } from "./work.mjs";
11
+ import { parseRun, lastRunText, readRunLog, briefPlan, stepOf, runLine } from "./work.mjs";
12
+ import { noteDuration, kindOf } from "./estimate.mjs";
12
13
  import { parseFacts, leftToYou } from "./handover.mjs";
13
14
  import { scanRoots, repoPathMap } from "./scan.mjs";
14
15
  import { knowledgeFolders } from "./knowledge.mjs";
@@ -343,8 +344,8 @@ function earlierRuns() {
343
344
  let end = Date.now(); if (!busy) try { end = statSync(log).mtimeMs; } catch {}
344
345
  const needs = busy ? null : needsOf(r.path, { final: lastFinal(r.path), waiting: wait, end });
345
346
  if (!busy && !ask.questions.length && !wait && !facts.length && !needs) continue;
346
- let tail = "", work = null, progress = null; try { ({ tail, work, progress } = workOf(r.path, readRunLog(log))); } catch {}
347
- out.push({ id: "earlier-" + digest(r.path).slice(0, 8), name: String(r.name || r.path.split("/").pop()), path: r.path, status: busy ? "running" : "done", earlier: true, startedAt, endedAt: busy ? null : end, elapsed: Math.max(0, end - startedAt), exitCode: null, tail, work, progress, changed: agentChanges(r.path, startedAt), ask, held: heldTasks(r.path), waiting: wait, needs, remember: facts.length ? facts : null, fromHeld: false });
347
+ let tail = "", work = null, progress = null, line = null; try { ({ tail, work, progress, line } = workOf(r.path, readRunLog(log))); } catch {}
348
+ out.push({ id: "earlier-" + digest(r.path).slice(0, 8), name: String(r.name || r.path.split("/").pop()), path: r.path, status: busy ? "running" : "done", earlier: true, startedAt, endedAt: busy ? null : end, elapsed: Math.max(0, end - startedAt), exitCode: null, tail, work, progress, ...(busy && line ? { line } : {}), changed: agentChanges(r.path, startedAt), ask, held: heldTasks(r.path), waiting: wait, needs, remember: facts.length ? facts : null, fromHeld: false });
348
349
  }
349
350
  return out;
350
351
  }
@@ -768,9 +769,10 @@ function track(name, cmd, cwd, onExit, env) {
768
769
  try { if (statSync(logp).size > LOG_KEEP) renameSync(logp, logp + ".old"); } catch {} // past 8 MB: kept as agent.log.old, and a fresh one starts
769
770
  let fd = "ignore"; try { fd = openSync(logp, "a"); writeSync(fd, `\n=== ${name} ${new Date().toISOString()} ===\n$ ${cmd}\n`); } catch {}
770
771
  const entry = { id: randomBytes(4).toString("hex"), name, path: cwd, log: logp, startedAt: Date.now(), status: "running", exitCode: null, endedAt: null };
772
+ const task = firstOpen(readSymbiot(cwd, "TASKS.md")); // what it's on, for how long runs like it take (estimate.mjs)
771
773
  const child = spawn(cmd, { shell: true, cwd, detached: true, stdio: ["ignore", fd === "ignore" ? "ignore" : fd, fd === "ignore" ? "ignore" : fd], ...(env ? { env: { ...process.env, ...env } } : {}) });
772
774
  entry.pid = child.pid;
773
- child.on("exit", (code) => { entry.status = code === 0 ? "done" : "failed"; entry.exitCode = code; entry.endedAt = Date.now(); if (onExit) try { onExit(code); } catch {} });
775
+ child.on("exit", (code) => { entry.status = code === 0 ? "done" : "failed"; entry.exitCode = code; entry.endedAt = Date.now(); if (code === 0) try { noteDuration({ path: cwd, cmd, task, ms: entry.endedAt - entry.startedAt }); } catch {} if (onExit) try { onExit(code); } catch {} });
774
776
  child.on("error", () => { entry.status = "failed"; entry.endedAt = Date.now(); });
775
777
  child.unref();
776
778
  HANDOFFS.unshift(entry);
@@ -1046,7 +1048,8 @@ function heldTasks(path) {
1046
1048
  // a step of yours it waits on. Then runs from before the app started that still
1047
1049
  // need you (earlierRuns).
1048
1050
  // A run's work, to draw: parsed from its log (work.mjs), with how far through its
1049
- // TASKS.md it is. tail is what to show as text: what it said, for a streaming
1051
+ // TASKS.md it is, and (line) where it is in its brief's numbered steps, in a line, and
1052
+ // whether it has gone quiet or is going round (work.mjs runLine). tail is what to show as text: what it said, for a streaming
1050
1053
  // run; the log's end, for another agent.
1051
1054
  // Claude Code in print mode (claude -p) streams each step as JSON, so the work
1052
1055
  // can be drawn as it happens (work.mjs). Added to a claude -p command that has no
@@ -1056,22 +1059,26 @@ function withStream(cmd) {
1056
1059
  if (!/^\s*claude\b/.test(c) || !/\s-p\b|\s--print\b/.test(c) || /--output-format\b/.test(c)) return c;
1057
1060
  return c + " --output-format stream-json --verbose";
1058
1061
  }
1062
+ const firstOpen = (md) => { const l = String(md || "").split("\n").find((x) => /^\s*-\s*\[ \]/.test(x)); return l ? l.replace(/^\s*-\s*\[ \]\s*/, "").trim() : ""; };
1059
1063
  function workOf(path, log) {
1060
1064
  const w = parseRun(lastRunText(log)), md = readSymbiot(path, "TASKS.md");
1061
1065
  const done = (md.match(/^\s*-\s*\[x\]/gim) || []).length, total = done + (md.match(/^\s*-\s*\[ \]/gm) || []).length;
1062
1066
  const tail = w.stream ? (w.final || w.said.join("\n\n") || (w.doing ? w.doing + "…" : "")) : String(log).slice(-1200);
1063
- 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;
1064
- return { tail, work, progress: total ? { done, total } : null };
1067
+ const plan = w.stream ? briefPlan(md) : [], line = w.stream && !w.final ? runLine({ plan, step: stepOf(plan, w.talk), work: w }) : null;
1068
+ const work = w.stream ? { model: w.model, doing: w.doing, moved: w.moved, looping: w.looping, 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;
1069
+ // its kind (a brief, or answers) from its command line, and the task it's on: what estimate.mjs compares it with
1070
+ if (line) { const c = String(log || "").slice(String(log || "").lastIndexOf("\n=== ")).split("\n")[2] || ""; line.kind = kindOf(c); line.task = firstOpen(md).slice(0, 200); }
1071
+ return { tail, work, progress: total ? { done, total } : null, ...(line ? { line } : {}) };
1065
1072
  }
1066
1073
  function agentsList() {
1067
1074
  const seen = new Set();
1068
1075
  return HANDOFFS.map((e) => {
1069
- const { tail, work, progress } = workOf(e.path, readRunLog(e.log));
1076
+ const { tail, work, progress, line } = workOf(e.path, readRunLog(e.log));
1070
1077
  const first = !seen.has(e.path); seen.add(e.path);
1071
1078
  const facts = first && e.status !== "running" ? factsOf(e.path) : [];
1072
1079
  const waiting = first && e.status !== "running" ? waitingFor(e.path) : null;
1073
1080
  const needs = first && e.status !== "running" ? needsOf(e.path, { final: work ? tail : "", waiting, end: e.endedAt || Date.now() }) : null;
1074
- return { id: e.id, name: e.name, path: e.path, status: e.status, startedAt: e.startedAt, endedAt: e.endedAt, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, work, progress, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, waiting, needs, remember: facts.length ? facts : null, fromHeld: !!e.fromHeld };
1081
+ return { id: e.id, name: e.name, path: e.path, status: e.status, startedAt: e.startedAt, endedAt: e.endedAt, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, work, progress, ...(e.status === "running" && line ? { line } : {}), changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, waiting, needs, remember: facts.length ? facts : null, fromHeld: !!e.fromHeld };
1075
1082
  }).concat(earlierRuns());
1076
1083
  }
1077
1084
 
package/estimate.mjs ADDED
@@ -0,0 +1,109 @@
1
+ // symbiot — Estimate: how long a running agent has left, as a range ("5–15 min").
2
+ // Garth asked for it with the step line on Home (2026-10-08): steve and argena had
3
+ // run for half an hour and nothing said whether that was nearly done or barely
4
+ // started. A run's length comes from its lane's own past runs, read from that lane's
5
+ // agent.log (each "=== name time ===" header, its "$ command" line and the run's
6
+ // final result, which carries duration_ms), and from the runs Symbiot saw end
7
+ // (durations.json: lane, kind, the task it was on, how long). Like for like first:
8
+ // the same lane and kind of task, then the same lane, then the same kind anywhere.
9
+ // A run that answers questions ("The user has answered…") is a different kind from
10
+ // one that takes a brief, and is never mixed with it. What's queued in the lane adds
11
+ // its own runs to the total.
12
+
13
+ import { join } from "node:path";
14
+ import { statSync, openSync, readSync, closeSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { CONFIG_DIR } from "./core.mjs";
16
+
17
+ const FILE = join(CONFIG_DIR, "durations.json"), KEEP = 400, MIN_SAMPLES = 3;
18
+ const kindOf = (cmd) => (/The user has answered|has answered:/i.test(String(cmd || "")) ? "answer" : "brief");
19
+
20
+ // A log's finished runs: [{ at, kind, ms }]. Read once and then only what's been added
21
+ // since (a lane's log grows by the second while its agent works); a log that shrank
22
+ // (rotated to agent.log.old) is read afresh.
23
+ const LOGS = new Map();
24
+ function runsInLog(file) {
25
+ let st; try { st = statSync(file); } catch { return []; }
26
+ let c = LOGS.get(file);
27
+ if (!c || st.size < c.size) c = { size: 0, rest: "", runs: [], cur: null };
28
+ if (st.size > c.size) {
29
+ let fd; try { fd = openSync(file, "r"); } catch { return c.runs; }
30
+ try {
31
+ for (let pos = c.size; pos < st.size;) {
32
+ const b = Buffer.alloc(Math.min(1 << 20, st.size - pos)); readSync(fd, b, 0, b.length, pos); pos += b.length;
33
+ const lines = (c.rest + b.toString("utf8")).split("\n"); c.rest = lines.pop();
34
+ for (const l of lines) take(c, l);
35
+ }
36
+ } catch {} finally { try { closeSync(fd); } catch {} }
37
+ c.size = st.size; LOGS.set(file, c);
38
+ }
39
+ return c.runs;
40
+ }
41
+ function take(c, l) {
42
+ const h = l.match(/^=== .* (\d{4}-\d\d-\d\dT[\d:.]+Z) ===$/);
43
+ if (h) { c.cur = { at: Date.parse(h[1]), kind: "brief", cmdNext: true }; return; }
44
+ if (!c.cur) return;
45
+ if (c.cur.cmdNext) { c.cur.cmdNext = false; if (l.startsWith("$ ")) { c.cur.kind = kindOf(l); return; } }
46
+ if (!l.startsWith("{") || !l.includes('"type":"result"') || l.length > 200000) return;
47
+ let e; try { e = JSON.parse(l); } catch { return; }
48
+ if (e && e.type === "result" && !e.is_error && e.duration_ms > 0) { c.runs.push({ at: c.cur.at, kind: c.cur.kind, ms: e.duration_ms }); c.cur = null; }
49
+ }
50
+ function laneRuns(path) { return [...runsInLog(join(path, ".symbiot", "agent.log.old")), ...runsInLog(join(path, ".symbiot", "agent.log"))]; }
51
+
52
+ function loadDurations(file = FILE) { try { const a = JSON.parse(readFileSync(file, "utf8")); return Array.isArray(a) ? a : []; } catch { return []; } }
53
+ // A run Symbiot saw end: its lane, kind, the task it was on (its first open one), how long.
54
+ function noteDuration({ path, cmd, task = "", ms }, file = FILE) {
55
+ if (!path || !(ms > 0)) return;
56
+ const a = loadDurations(file); a.push({ path, kind: kindOf(cmd), task: String(task).slice(0, 200), ms: Math.round(ms), at: Date.now() });
57
+ try { writeFileSync(file, JSON.stringify(a.slice(-KEEP))); } catch {}
58
+ }
59
+
60
+ const q = (xs, p) => { const s = [...xs].sort((a, b) => a - b), i = (s.length - 1) * p, lo = Math.floor(i); return s[lo] + (s[Math.ceil(i)] - s[lo]) * (i - lo); };
61
+ // minutes, as people say them: 1–10 as they are, then to the nearest 5, then to the nearest 15 past an hour
62
+ const mins = (ms) => { const m = Math.max(1, Math.round(ms / 60000)); return m <= 10 ? m : m <= 60 ? Math.round(m / 5) * 5 : Math.round(m / 15) * 15; };
63
+ const span = (lo, hi) => { const a = mins(lo), b = Math.max(mins(hi), a); const f = (m) => (m >= 60 ? (m % 60 ? `${Math.floor(m / 60)} h ${m % 60}` : `${m / 60} h`) : `${m}`); return a === b ? `about ${f(a)}${a >= 60 && !(a % 60) ? "" : " min"}` : `${f(a)}–${f(b)}${b >= 60 && !(b % 60) ? "" : " min"}`; };
64
+
65
+ // The sets of past lengths to go on, like for like first: [{ ms: [..], from }].
66
+ function sampleSets({ path, kind = "brief", type = "", typeOf = () => "", durations = loadDurations(), runs = null } = {}) {
67
+ const own = durations.filter((d) => d.path === path), log = runs || laneRuns(path), sets = [];
68
+ if (type) sets.push({ ms: own.filter((d) => d.kind === kind && typeOf(d.task) === type).map((d) => d.ms), from: "lane+type" });
69
+ sets.push({ ms: log.filter((r) => r.kind === kind).map((r) => r.ms), from: "lane+kind" });
70
+ sets.push({ ms: log.map((r) => r.ms), from: "lane" });
71
+ if (type) sets.push({ ms: durations.filter((d) => d.kind === kind && typeOf(d.task) === type).map((d) => d.ms), from: "type" });
72
+ return sets.map((x) => ({ ...x, ms: x.ms.slice(-30) }));
73
+ }
74
+ // How long it has left: { left: "5–15 min", lo, hi (ms), from, queued, total: with
75
+ // what's queued behind it }. Judged on the past runs that lasted longer than this one
76
+ // has so far (a run already 20 minutes in isn't one of the 1-minute replies), from the
77
+ // first set with enough of them. Past nearly all of them: { over, usual: "5–25 min" }.
78
+ // null with too little to go on. queued: tasks held for the lane's next run
79
+ // (TASKS.next.md), which is one more run however many tasks it holds.
80
+ function estimate({ path, kind = "brief", type = "", typeOf, elapsed = 0, queued = 0, durations, runs } = {}) {
81
+ const sets = sampleSets({ path, kind, type, typeOf, durations, runs });
82
+ let out = null;
83
+ for (const st of sets) {
84
+ const left = st.ms.filter((m) => m > elapsed).map((m) => m - elapsed);
85
+ if (left.length < MIN_SAMPLES) continue;
86
+ const lo = Math.max(q(left, 0.25), 60000), hi = Math.max(q(left, 0.75), lo);
87
+ out = { left: span(lo, hi), lo: Math.round(lo), hi: Math.round(hi), from: st.from }; break;
88
+ }
89
+ if (!out) {
90
+ const all = sets.find((st) => st.ms.length >= MIN_SAMPLES && st.from === "lane") || sets.find((st) => st.ms.length >= MIN_SAMPLES);
91
+ if (!all) return null;
92
+ return { over: true, usual: span(q(all.ms, 0.25), q(all.ms, 0.75)), from: all.from };
93
+ }
94
+ if (queued > 0) {
95
+ // the next run takes a brief, whatever this one is: the lane's briefs, else what this one was judged on
96
+ const briefs = sampleSets({ path, kind: "brief", durations, runs }).find((st) => st.from === "lane+kind"), b = briefs && briefs.ms.length >= MIN_SAMPLES ? briefs : sets.find((st) => st.ms.length >= MIN_SAMPLES);
97
+ out.queued = queued; out.total = span(out.lo + q(b.ms, 0.25), out.hi + q(b.ms, 0.75));
98
+ }
99
+ return out;
100
+ }
101
+ // In words, after the step line: "5–15 min left", "+2 queued: 40–90 min in all",
102
+ // "longer than its usual 5–25 min".
103
+ function estimateWords(e) {
104
+ if (!e) return "";
105
+ if (e.over) return `longer than its usual ${e.usual.replace(/^about /, "")}`;
106
+ return `${e.left} left${e.queued ? ` · +${e.queued} queued: ${e.total} in all` : ""}`;
107
+ }
108
+
109
+ export { estimate, estimateWords, sampleSets, noteDuration, loadDurations, runsInLog, laneRuns, kindOf, mins, span };
package/home.mjs CHANGED
@@ -13,7 +13,8 @@ import { join } from "node:path";
13
13
  import { homedir } from "node:os";
14
14
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
15
  import { watchBoard, draftReply } from "./watch.mjs";
16
- import { pendingReview, pushTasks, addTask } from "./tasks.mjs";
16
+ import { pendingReview, pushTasks, addTask, taskType } from "./tasks.mjs";
17
+ import { estimate, estimateWords } from "./estimate.mjs";
17
18
  import { agentsList, runHandoff, runningHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent, detectHandoffs, linkReach } from "./agents.mjs";
18
19
  import { linksState } from "./links.mjs";
19
20
  import { knowledgeState } from "./knowledge.mjs";
@@ -306,9 +307,16 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
306
307
  if (rep.count) feeds.push({ id: "feed:reports", title: "Reports", sub: `${rep.count} unread`, latest: rep.latest, count: rep.count, shape: "reports" });
307
308
  const hs = ((lanes() || {}).handoffs || []).slice(0, 5).map((h) => ({ from: h.from, to: h.to, text: h.text, status: h.status, ...(h.times > 1 ? { times: h.times } : {}) }));
308
309
  const working = list.filter((a) => a.status === "running").length;
310
+ // each run at work, in a line: which lane, where it is in its brief, how long it has left, and whether it has gone quiet
311
+ const seenRun = new Set(), runs = [];
312
+ for (const a of list) {
313
+ if (a.status !== "running" || seenRun.has(a.path)) continue; seenRun.add(a.path);
314
+ const lane = laneOfPath(a.path, map);
315
+ runs.push({ id: "run:" + a.path, path: a.path, repo: lane || "", name: lane ? named(map[lane], lane) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, ""), 40), ...runNow(a), started: a.startedAt || 0 });
316
+ }
309
317
  // nothing waits on you: a few things that can be done next (none while anything does)
310
318
  const next = you.length ? [] : nextUp({ now, map, list, named, deps: { board: () => bd, reports: () => rep, ...(deps.next || {}) } });
311
- const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, next, marketing: marketing({ list, map, named, pend }), at: now };
319
+ const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, runs: runs.slice(0, 4), next, marketing: marketing({ list, map, named, pend }), at: now };
312
320
  if (!deps.board) cached = out;
313
321
  return out;
314
322
  }
@@ -393,7 +401,8 @@ function homeContext(h) {
393
401
  return [
394
402
  `Only the user can do (${h.you.length}):`, ...h.you.map((y) => `- ${y.title}: ${y.sub}${y.q && y.q !== y.sub ? ` (asks: ${y.q}${(y.options || []).length ? ` ${y.options.join(" / ")}` : ""})` : ""}`),
395
403
  `New on what they watch:`, ...(h.feeds.length ? h.feeds.map((f) => `- ${f.title}: ${f.sub}${f.latest ? ` (latest: ${f.latest})` : ""}`) : ["- nothing new"]),
396
- `Agents working: ${h.working}. Recent handovers:`, ...(h.lanes.length ? h.lanes.map((l) => `- ${l.from} → ${l.to}: ${l.text} (${l.status}${l.times ? `, handed over ${l.times} times` : ""})`) : ["- none"]),
404
+ `Agents working: ${h.working}.`, ...(h.runs || []).map((r) => `- ${r.name}: ${r.line}${r.eta ? ` (${r.eta})` : ""}${r.quiet ? ` (no progress in ${r.quiet} min)` : ""}${r.looping ? " (repeating the same step)" : ""}`),
405
+ "Recent handovers:", ...(h.lanes.length ? h.lanes.map((l) => `- ${l.from} → ${l.to}: ${l.text} (${l.status}${l.times ? `, handed over ${l.times} times` : ""})`) : ["- none"]),
397
406
  ...(h.marketing ? [`Marketing (a lane of its own, for marketing across all their products; "repo": "${MARKETING}"): ${h.marketing.needs ? `${h.marketing.needs} thing${h.marketing.needs > 1 ? "s" : ""} there need${h.marketing.needs > 1 ? "" : "s"} them` : "nothing needs them"}${h.marketing.working ? ", its agent is at work" : ""}.`] : []),
398
407
  ...((h.next || []).length ? ["Could be done next (Home suggests these, one tap each):", ...h.next.map((s) => `- ${s.title}: ${s.gain} (${s.time})`)] : []),
399
408
  ].join("\n");
@@ -425,6 +434,16 @@ async function homeAsk(question, { ask, now = Date.now(), state, images = [] } =
425
434
  // there's anything a Go would start. The details (steps, files, cost) stay one
426
435
  // tap away, in the Agents panel.
427
436
  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; };
437
+ // A running agent's line (agents.mjs workOf → work.mjs runLine): "step 2 of 4: look
438
+ // and feel, plus a zoned world · shooting the monsters", with quiet (minutes since it
439
+ // last moved, once that's long) and looping. Without one, what it's doing now. eta:
440
+ // how long it has left, as a range from the lane's past runs of its kind and what's
441
+ // held for its next run (estimate.mjs), when there's enough to go on.
442
+ function runNow(a, { now = Date.now(), est = estimate } = {}) {
443
+ const l = a.line, w = a.work || {}, todo = (w.todos || []).find((t) => t.status === "in_progress");
444
+ let eta = null; try { if (l && a.path) eta = est({ path: a.path, kind: l.kind || "brief", type: l.task ? taskType(l.task) : "", typeOf: taskType, elapsed: a.startedAt ? now - a.startedAt : a.elapsed || 0, queued: (a.held || []).length }); } catch {}
445
+ return { line: plain(l ? l.line : todo ? todo.active : w.doing || "working on it", 140), ...(l && l.step ? { step: l.step, of: l.of } : {}), ...(l && l.quiet ? { quiet: l.quiet } : {}), ...(l && l.looping ? { looping: true } : {}), ...(eta ? { eta: estimateWords(eta), etaLo: eta.lo, etaHi: eta.hi } : {}) };
446
+ }
428
447
  // Projects (the lanes) are sorted the way you'd look for them: what needs you
429
448
  // first (a question, an Approve), then what's at work, ready, waiting, and the
430
449
  // most recent. Ops runs (act-…: no repo of their own) are one "Agent runs" lane,
@@ -446,14 +465,14 @@ function workScene({ deps = {} } = {}) {
446
465
  if (nq) asks[laneOf(a)] = (asks[laneOf(a)] || 0) + nq;
447
466
  if (a.status !== "running") continue;
448
467
  const w = a.work || {}, todo = (w.todos || []).find((t) => t.status === "in_progress"), pg = a.progress;
449
- running.push({ id: "run:" + a.path, path: a.path, name: a.name, lane: laneOf(a), doing: plain(todo ? todo.active : w.doing || "Working on it", 120), progress: pg || null, waiting: !!nq, started: a.startedAt || (a.elapsed ? Date.now() - a.elapsed : 0) });
468
+ running.push({ id: "run:" + a.path, path: a.path, name: a.name, lane: laneOf(a), doing: plain(todo ? todo.active : w.doing || "Working on it", 120), progress: pg || null, ...runNow(a), waiting: !!nq, started: a.startedAt || (a.elapsed ? Date.now() - a.elapsed : 0) });
450
469
  }
451
470
  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 }));
452
471
  const busy = new Set(running.map((r) => r.lane)), open = tasks().filter((t) => !t.done && !t.archived && !t.review && t.repo);
453
472
  const waiting = open.map((t) => ({ id: "task:" + t.id, text: plain(t.text, 160), repo: t.repo, busy: busy.has(t.repo), ...(isParked(t.repo) ? { parked: true } : {}) })); // the app shortens it for a tag
454
473
  // Projects: each lane with work on it, what's going on there in one line's worth
455
474
  const by = {}, at = (name) => (by[name] = by[name] || { repo: name, name: name === RUNS_LANE ? "Agent runs" : named(map[name], name), waiting: 0, ready: 0, asks: 0, running: null, last: 0, ...(name === RUNS_LANE ? { runs: 0 } : {}) });
456
- running.forEach((r) => { const p = at(r.lane); if (r.lane === RUNS_LANE) p.runs++; if (!p.running) p.running = { doing: r.doing, progress: r.progress, ask: r.waiting }; p.last = Math.max(p.last, r.started || 0); });
475
+ running.forEach((r) => { const p = at(r.lane); if (r.lane === RUNS_LANE) p.runs++; if (!p.running) p.running = { doing: r.doing, progress: r.progress, ask: r.waiting, ...(r.line ? { line: r.line } : {}), ...(r.eta ? { eta: r.eta } : {}), ...(r.quiet ? { quiet: r.quiet } : {}), ...(r.looping ? { looping: true } : {}) }; p.last = Math.max(p.last, r.started || 0); });
457
476
  ready.forEach((r) => { at(r.repo).ready = r.count || 1; });
458
477
  open.forEach((t) => { const p = at(t.repo); p.waiting++; p.last = Math.max(p.last, Number(t.ts) || 0); });
459
478
  Object.keys(asks).forEach((l) => { at(l).asks = asks[l]; });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.57.2",
3
+ "version": "0.57.4",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,7 +47,8 @@
47
47
  "marketing.mjs",
48
48
  "postinstall.mjs",
49
49
  "icon.svg",
50
- "scanworker.mjs"
50
+ "scanworker.mjs",
51
+ "estimate.mjs"
51
52
  ],
52
53
  "engines": {
53
54
  "node": ">=18"
package/tasks.mjs CHANGED
@@ -568,7 +568,7 @@ function buildTasksMd(name, ctx, list) {
568
568
  const byType = {}; for (const t of list) { const ty = taskType(t.text); (byType[ty] = byType[ty] || []).push(t); }
569
569
  const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
570
570
  for (const ty of keys) { L.push(`### ${ty}`); for (const t of byType[ty]) L.push(`- [ ] ${t.text}`); L.push(""); }
571
- L.push("## When you finish an item", "- Tick it here (`- [x]`) as soon as it's done — that's how it reaches review. Ticking doesn't archive it: the user approves it in Symbiot, which commits it on a branch and opens a PR.", "- Leave your changes **uncommitted**, and don't tick anything you didn't finish or couldn't verify.", "- If the user says to drop an item (in ANSWERS.md, say), delete its line here: Symbiot closes it, so it isn't sent again. Don't delete one for any other reason.", "");
571
+ L.push("## When you finish an item", "- Tick it here (`- [x]`) as soon as it's done — that's how it reaches review. Ticking doesn't archive it: the user approves it in Symbiot, which commits it on a branch and opens a PR.", "- Leave your changes **uncommitted**, and don't tick anything you didn't finish or couldn't verify.", "- If an item has numbered steps (1), 2), …), say `Step N: …` as you start each one, so Home shows how far along you are.", "- If the user says to drop an item (in ANSWERS.md, say), delete its line here: Symbiot closes it, so it isn't sent again. Don't delete one for any other reason.", "");
572
572
  L.push(...handoverRules(ctx.lanes || [], name), ...HANDBACK);
573
573
  L.push("## If you need a decision, or have ideas", "You may be running unattended, so you can't ask in chat. Write `.symbiot/QUESTIONS.md` instead: Symbiot shows it to the user on your block in its Workdesk, and their answers come back in `.symbiot/ANSWERS.md` (read that first if it exists).",
574
574
  `- At most ${QUESTIONS_MAX} questions, under a \`## Questions\` heading. Each is a \`### \` heading, then a line of context, then exactly ${OPTIONS_SHOWN} options as \`- \` bullets, the one you recommend first, marked \`(recommended)\`. Symbiot shows only the first ${OPTIONS_SHOWN}; the user can always answer in their own words.`,
package/ui.mjs CHANGED
@@ -27,8 +27,13 @@ body.lq-forced #lq{display:none}
27
27
  .lqd .lt .lm{display:block;font-weight:500;font-size:11.5px;color:#8A919B;margin-top:1px}
28
28
  .lqd .lt{all:unset;box-sizing:border-box;display:flex;align-items:center;justify-content:center;gap:7px;min-height:32px;padding:4px 8px;cursor:pointer;text-align:center;max-width:170px}
29
29
  .lqd.lq-proj .lt{flex-direction:column;gap:1px;max-width:190px}
30
+ .lqd.lq-run .lt{flex-direction:column;gap:1px;max-width:220px}
31
+ .lqd.lq-band .lt{flex-direction:column;align-items:flex-start;gap:1px;text-align:left;max-width:270px;width:270px;padding:2px 6px;min-height:0}
32
+ .lqd.lq-band .lt>span:first-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
33
+ .lqd.lq-band .lt .lm{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;max-width:100%}
34
+ .lqd.lq-band small,.lqd.lq-band .lgo{display:none}
30
35
  .lqd.lq-proj .lt .lm{font-weight:500;font-size:11.5px;color:#8A919B;max-width:180px;-webkit-line-clamp:1}
31
- body.lq-light .lqd.lq-proj .lt .lm{color:#5A6470}
36
+ body.lq-light .lqd.lq-proj .lt .lm,body.lq-light .lqd.lq-run .lt .lm{color:#5A6470}
32
37
  .lqd.lq-you .lt::before,.lqd.lq-lit .lt::before{content:'';flex:none;width:7px;height:7px;border-radius:50%;background:#F2A541;box-shadow:0 0 0 3px rgba(242,165,65,.2)}
33
38
  .lqd .lt span{overflow:hidden;white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:12.5px;line-height:1.3;overflow-wrap:anywhere}
34
39
  .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}
@@ -2552,8 +2557,8 @@ function lqRedraw(){whenFree($('liquid'),'liquid',function(){if(LQ.scene==='work
2552
2557
  function lqBuildWork(){lqRelay();if(LQ.workBy==='project'){lqBuildProjects();return;}var w=LQ.workData||{},S=lqSize(),cx=S.w/2,cy=S.h*0.5,list=[];LQ.bw=S.w;LQ.bh=S.h;if(lqRelayOn()){var RW=Math.min(420,S.w*0.32)+28;S={w:S.w-RW,h:S.h,s:S.s};cx=S.w/2;}
2553
2558
  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);
2554
2559
  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;
2555
- 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':'';
2556
- list.push({id:r.id,kind:'run',repo:r.name,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});});
2560
+ run.forEach(function(r,i){var ang=Math.PI+(i+1)*Math.PI/(run.length+1);var pg=r.step?' · step '+r.step+' of '+r.of:r.progress?' · '+r.progress.done+' of '+r.progress.total+' done':'';
2561
+ list.push({id:r.id,kind:'run',repo:r.name,shape:'agents',ask:r.waiting||!!(r.quiet||r.looping),title:r.doing,sub:r.name+pg+(r.eta?' · '+r.eta:'')+(r.waiting?' · has a question for you':'')+(r.quiet||r.looping?' · '+runWarn(r).replace(/ · $/,''):''),r:58*rs,tx:cx+Math.cos(ang)*190*vx,ty:cy+Math.sin(ang)*130*vy});});
2557
2562
  ready.forEach(function(r,i){var ang=-Math.PI/2+(i-(ready.length-1)/2)*0.9;list.push({id:r.id,kind:'ready',repo:r.repo,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});});
2558
2563
  wait.forEach(function(t,i){var ang=Math.PI/2+(i-(wait.length-1)/2)*0.55;list.push({id:t.id,kind:'wait',repo:t.repo,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});});
2559
2564
  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});
@@ -2581,7 +2586,7 @@ function lqBuildProjects(){var w=LQ.workData||{},S=lqSize(),cx=S.w/2,cy=S.h*0.5,
2581
2586
  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);
2582
2587
  var all=lqSortLanes((w.projects||[]).slice()),cap=S.w<700?5:7,ps=all.slice(0,cap),rest=all.slice(cap),slots=Math.max(3,ps.length+(rest.length?1:0));
2583
2588
  ps.forEach(function(p,i){var ang=Math.PI+i*2*Math.PI/slots,load=p.waiting+2*p.ready+(p.running?3:0)+2*Math.min(2,p.asks||0);
2584
- var sub=p.asks?p.asks+' question'+(p.asks>1?'s':'')+' for you':p.ready?'ready for your OK':p.runs>1?p.runs+' agents at work':p.running?(p.running.ask?'has a question for you':'agent at work: '+String(p.running.doing||'').toLowerCase()):p.waiting+' task'+(p.waiting>1?'s':'')+' waiting';if(p.parked)sub='parked \\u00b7 '+sub;
2589
+ var sub=p.asks?p.asks+' question'+(p.asks>1?'s':'')+' for you':p.ready?'ready for your OK':p.runs>1?p.runs+' agents at work':p.running?(p.running.ask?'has a question for you':p.running.quiet||p.running.looping?runWarn(p.running).replace(/ · $/,''):p.running.line?String(p.running.line)+(p.running.eta?' · '+p.running.eta:''):'agent at work: '+String(p.running.doing||'').toLowerCase()):p.waiting+' task'+(p.waiting>1?'s':'')+' waiting';if(p.parked)sub='parked \\u00b7 '+sub;
2585
2590
  list.push({id:'proj:'+p.repo,kind:'proj',repo:p.repo,shape:'tasks',title:p.name||p.repo,sub:sub,orbit:!!p.running,ask:!!p.lit,lit:!!p.lit,parked:!!p.parked,r:(30+7*Math.min(6,load))*rs,tx:cx+Math.cos(ang)*260*vx,ty:cy+Math.sin(ang)*260*vy});});
2586
2591
  if(rest.length){var am=Math.PI+ps.length*2*Math.PI/slots;list.push({id:'moreproj',kind:'more',title:'+'+rest.length+' more',sub:'quieter: '+rest.map(function(p){return p.name||p.repo;}).join(' · '),lanes:rest.map(function(p){return {repo:p.repo,name:p.name||p.repo,lit:!!p.lit};}),r:24*rs,tx:cx+Math.cos(am)*260*vx,ty:cy+Math.sin(am)*260*vy});}
2587
2592
 
@@ -2630,7 +2635,7 @@ function lqLoad(commit){if(LQ.scene==='work'){lqLoadWork();return;}var fresh=LQ.
2630
2635
  var PARA_OF={tasks:'p',agents:'p',todo:'p',marketing:'p',board:'a',week:'a',standup:'a',map:'r',drift:'r',settings:'r',reports:'r'};
2631
2636
  var PARA_NAME={p:'Projects',a:'Areas',w:'Watching',r:'Resources',x:'Archive'},PARA_SUB={p:'work with an end',a:'what you keep up with',w:'new on what you watch',r:'to look things up in',x:'what’s done'};
2632
2637
  var PARA_ANG={p:-2.55,a:-0.6,w:0.3,r:2.6,x:1.25},PARA_LIVE={p:0.45,a:0.3,w:0.3,r:0.12,x:0};
2633
- function lqPara(d){return d.kind==='you'?'n':d.kind==='archive'?'x':d.kind==='feed'&&d.shape!=='reports'?'w':PARA_OF[d.shape]||'p';}
2638
+ function lqPara(d){return d.kind==='you'?'n':d.band?'b':d.kind==='archive'?'x':d.kind==='feed'&&d.shape!=='reports'?'w':PARA_OF[d.shape]||'p';}
2634
2639
  function lqParaGo(g){lqAct();if(g==='p'){lqWorkBy('project');return;}if(g==='a'||g==='w'){lqUse('board');lqPool('board');return;}if(g==='r'){lqUse('map');lqPool('map');return;}lqArchive();}
2635
2640
  // The needs-you band: a lit orb per thing only you can do, its blob beside it (the
2636
2641
  // project, the question, its two answers), as many across as fit, in a steady
@@ -2638,11 +2643,23 @@ function lqParaGo(g){lqAct();if(g==='p'){lqWorkBy('project');return;}if(g==='a'|
2638
2643
  var LQ_BLOBW=250,LQ_NEEDY=128;
2639
2644
  function lqNeeds(h,S){var rank={ask:0,approve:1,setup:2},rk=function(y){return y.urgent?(y.id==='setup:ai'?-3:y.id==='setup:agent'?-2:-1):rank[y.kind]||0;},ys=(h.you||[]).slice().sort(function(a,b){return rk(a)-rk(b)||(a.id<b.id?-1:a.id>b.id?1:0);});
2640
2645
  var nb=lqNextBox(h,S);if(!ys.length)return {items:[],bottom:nb,x:0,page:0,pages:1,count:0};
2641
- var R=Math.round(24*Math.max(0.85,Math.min(1.1,S.s))),unit=2*R+30+LQ_BLOBW,gap=28,cols=Math.max(1,Math.min(3,Math.floor((S.w-24+gap)/(unit+gap)))),pages=Math.max(1,Math.ceil(ys.length/cols));
2646
+ var R=Math.round(24*Math.max(0.85,Math.min(1.1,S.s))),unit=3*R+22+LQ_BLOBW,gap=28,cols=Math.max(1,Math.min(3,Math.floor((S.w-24+gap)/(unit+gap)))),pages=Math.max(1,Math.ceil(ys.length/cols));
2642
2647
  LQ.needPage=(LQ.needPage||0)%pages;var shown=ys.slice(LQ.needPage*cols,LQ.needPage*cols+cols),x0=Math.max(12,(S.w-(shown.length*unit+(shown.length-1)*gap))/2);
2643
2648
  var tall=(shown.some(function(y){return y.kind==='ask'&&(y.options||[]).length;})?172:116)+(shown.some(function(y){return y.fix==='handover';})?16:0);
2644
2649
  var items=shown.map(function(y,i){return {id:y.id,kind:'you',blob:true,item:y,shape:y.shape,title:y.title,sub:y.sub,r:R,tx:x0+i*(unit+gap)+R,ty:LQ_NEEDY+R,ax:0,ay:0};});
2645
2650
  return {items:items,bottom:items.length?LQ_NEEDY+tall+20:0,x:x0,page:LQ.needPage,pages:pages,count:ys.length};}
2651
+ // The agents at work, a band of their own under the needs-you band (or the header):
2652
+ // a small orb each, its lane's name in full and, under it, where it is in its brief
2653
+ // ("step 2 of 4: …"), what it's on and how long it has left, amber once it has gone
2654
+ // quiet or round in circles. Among the groups they pushed the headings off their
2655
+ // own orbs and squeezed the names letter by letter (2026-10-08), so they never join
2656
+ // a group: the groups lay out under the band, as if it were the header.
2657
+ var LQ_RUNW=270;
2658
+ function lqRunBand(h,S,y){var rs=(h.runs||[]);if(!rs.length)return {items:[],bottom:0};
2659
+ var R=Math.round(15*Math.max(0.85,Math.min(1.1,S.s))),unit=2*R+50+LQ_RUNW,gap=22,cols=Math.max(1,Math.min(4,Math.floor((S.w-24+gap)/(unit+gap)))),shown=rs.slice(0,cols);
2660
+ var x0=Math.max(12,(S.w-(shown.length*unit+(shown.length-1)*gap))/2),y0=Math.max(y||0,84)+18;
2661
+ var items=shown.map(function(x,i){return {id:x.id,kind:'run',band:true,shape:'agents',repo:x.repo,title:x.name,sub:runWarn(x)+x.line+(x.eta?' · '+x.eta:''),ask:!!(x.quiet||x.looping),r:R,tx:x0+i*(unit+gap)+R,ty:y0+22,ax:0,ay:0};});
2662
+ return {items:items,bottom:y0+44+16,more:rs.length-shown.length};}
2646
2663
  // Next up, where the needs-you band would be: with nothing waiting on you (and only
2647
2664
  // then), a few things that CAN be done next (home.mjs nextUp), each with its gain
2648
2665
  // and one tap that starts an agent or makes a task. × puts the box away until what
@@ -2683,12 +2700,13 @@ var gs=Object.keys(groups).map(function(k){var g=groups[k];return {key:k,m:g,ang
2683
2700
  gs.sort(function(a,b){return a.ang-b.ang;});var tot=gs.reduce(function(t,g){return t+g.m.length+0.8;},0),acc=gs.length?gs[0].ang-(gs[0].m.length+0.8)/tot*Math.PI:0;
2684
2701
  gs.forEach(function(g){var span=(g.m.length+0.8)/tot*2*Math.PI,mid=acc+span/2+(lqOrg(list[g.m[0]].id,3)-0.5)*span*0.25;acc+=span;if(angOf){span=Math.min(1.5,span);mid=angOf(g.key)+(lqOrg(list[g.m[0]].id,3)-0.5)*0.2;}
2685
2702
  g.m.forEach(function(i,k){var d=list[i],rr=Math.sqrt(Math.pow((d.ax-cx)/vx,2)+Math.pow((d.ay-cy)/vy,2))*(k%2?1.22:0.92),th=mid+(k-(g.m.length-1)/2)*Math.min(0.62,span/Math.max(1,g.m.length)*0.8);d.tx=cx+Math.cos(th)*rr*vx;d.ty=cy+Math.sin(th)*rr*vy;});});}
2703
+ function runWarn(x){return x.quiet?'no progress in '+x.quiet+' min · ':x.looping?'repeating the same step · ':'';}
2686
2704
  function lqLinks(list,near){var at={},links=[],same=function(l){return lqPara(list[l[0]])===lqPara(list[l[1]]);};list.forEach(function(d,i){if(d.kind==='shape')at[d.shape]=i;});
2687
2705
  list.forEach(function(d,i){if(d.kind==='shape')((near||{})[d.shape]||[]).forEach(function(n){var j=at[n.id];if(j!=null&&j>i)links.push([i,j,n.w]);else if(j!=null&&j<i&&!links.some(function(l){return l[0]===j&&l[1]===i;}))links.push([j,i,n.w]);});
2688
2706
  else if((d.kind==='you'||d.kind==='feed')&&at[d.shape]!=null)links.push([at[d.shape],i,0.5]);});return links.filter(same);}
2689
2707
  function lqBuild(){var a=LQ.adapt||{},h=LQ.home||{},S=lqSize(),list=[];LQ.bw=S.w;LQ.bh=S.h;
2690
2708
  // the band first: everything else sits under it, round a core centred in what's left
2691
- var need=lqNeeds(h,S),top=need.bottom,cx=S.w/2,cy=top?Math.max(S.h*0.47,(top+60+S.h-200)/2):S.h*0.47;LQ.cy=cy;LQ.top=top;LQ.need=need;
2709
+ var need=lqNeeds(h,S),band=lqRunBand(h,S,need.bottom),top=band.bottom||need.bottom,cx=S.w/2,cy=top?Math.max(S.h*0.47,(top+60+S.h-200)/2):S.h*0.47;LQ.cy=cy;LQ.top=top;LQ.need=need;
2692
2710
  // Marketing only once posts can be drafted, or are (home.mjs): a new user meets what they watch first
2693
2711
  var mk=function(id){return id!=='agents'&&(id!=='marketing'||!!h.marketing);}; // and Agents is the Workdesk (tasks), one orb
2694
2712
  var feeds=(h.feeds||[]).slice(0,2),lay=((a.layout&&a.layout.items)||[]).filter(function(it){return mk(it.id);}),more=((a.layout&&a.layout.more)||[]).filter(mk);
@@ -2715,11 +2733,11 @@ for(;;){cur=base.slice();if(more.length)cur.push({id:'more',kind:'more',title:'m
2715
2733
  cur.forEach(function(d){d.r=d.r0;d.tx=d.tx0;d.ty=d.ty0;});if(lqRelax(cur,S,cx,cy,92*S.s,top?top+60:0)||tries++>=8)break;
2716
2734
  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);}
2717
2735
  list=cur;}
2718
- list=list.concat(need.items);var old={};LQ.drops.forEach(function(d){old[d.id]=d;});
2736
+ list=list.concat(need.items,band.items);var old={};LQ.drops.forEach(function(d){old[d.id]=d;});
2719
2737
  // a new blob grows where it belongs, rather than sweeping out of the core across the rest
2720
2738
  LQ.drops=list.map(function(d){var o=old[d.id];d.x=o?o.x:d.blob?d.tx:cx;d.y=o?o.y:d.blob?d.ty:cy;d.vx=o?o.vx:0;d.vy=o?o.vy:0;d.cr=o?o.cr:0;return d;});
2721
- var n=(h.you||[]).length,w=h.working||0;
2722
- 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':'Nothing needs you right now');
2739
+ var n=(h.you||[]).length,w=h.working||0,qt=(h.runs||[]).filter(function(x){return x.quiet||x.looping;}).length,wk=w?w+' agent'+(w>1?'s':'')+' working'+(qt?' ('+qt+' stalled)':''):'';
2740
+ LQ.coreText=n?(n+(n>1?' things need':' thing needs')+' only you'+(w?' · '+wk:'')):(w?wk+' · nothing needs you':'Nothing needs you right now');
2723
2741
  var b=document.body;if(b&&b.classList){b.classList.toggle('lq-touch',!!(a.modes&&a.modes.touch)||LQ.touch);b.classList.toggle('lq-keys',!!(a.modes&&a.modes.keyboard));}
2724
2742
  LQ.talkWeight=(a.modes&&a.modes.talkWeight)||0.35;
2725
2743
  lqLabels();}
@@ -2784,7 +2802,7 @@ while(out.indexOf(' ')>=0)out=out.split(' ').join(' ');[' :',' ,',' ;',' .'].f
2784
2802
  function lqLabels(){var el=$('lqdrops');if(!el)return;
2785
2803
  var dropAt=function(b){return LQ.drops[+b.getAttribute('data-i')];};
2786
2804
  var same=sameHtml(el,LQ.drops.map(function(d,i){if(d.blob)return "<div class='lqd lq-you lqblob"+(d.item.urgent?' urgent':'')+"' data-i='"+i+"' role='group' aria-label='"+escQ(String(d.item.name||d.item.title||'')+': '+String(d.item.q||d.item.sub||''))+"'>"+blobBody(d.item,true)+"</div>";
2787
- return "<div class='lqd lq-"+d.kind+(d.parked?' lq-parked':'')+(d.ask&&d.kind==='shape'?' lq-lit':'')+(LQ.openTag===d.id?' open':'')+"' data-i='"+i+"'><button type='button' class='lt' title='"+escQ(String(d.title||'').split('**').join('')+(d.sub?' · '+d.sub:''))+"' aria-expanded='"+(LQ.openTag===d.id)+"'><span>"+esc(lqShort(d.title,42))+"</span>"+(d.kind==='proj'&&d.sub?"<span class='lm'>"+esc(lqShort(d.sub,40))+"</span>":"")+"</button>"+(d.sub?"<small>"+esc(d.sub)+"</small>":"")+"<button type='button' class='lgo'>Open &rsaquo;</button></div>";}).join(''));
2805
+ return "<div class='lqd lq-"+d.kind+(d.band?' lq-band':'')+(d.parked?' lq-parked':'')+(d.ask&&d.kind==='shape'?' lq-lit':'')+(LQ.openTag===d.id?' open':'')+"' data-i='"+i+"'><button type='button' class='lt' title='"+escQ(String(d.title||'').split('**').join('')+(d.sub?' · '+d.sub:''))+"' aria-expanded='"+(LQ.openTag===d.id)+"'><span>"+esc(lqShort(d.title,42))+"</span>"+((d.kind==='proj'||d.kind==='run')&&d.sub?"<span class='lm'>"+esc(lqShort(d.sub,d.kind==='proj'?40:56))+"</span>":"")+"</button>"+(d.sub?"<small>"+esc(d.sub)+"</small>":"")+"<button type='button' class='lgo'>Open &rsaquo;</button></div>";}).join(''));
2788
2806
  if(!same){LQ.btns=[];LQ.lts=[];el.querySelectorAll('.lqd').forEach(function(b){LQ.btns.push(b);var d=dropAt(b),lt=b.querySelector('.lt'),go=b.querySelector('.lgo');LQ.lts.push(lt);
2789
2807
  if(lt)lt.addEventListener('click',function(ev){lqOpen(dropAt(b),ev);});
2790
2808
  if(go)go.addEventListener('click',function(ev){LQ.openTag=null;lqOpen(dropAt(b),ev);});
@@ -2936,16 +2954,19 @@ var D=LQ.drops,N=D.length;
2936
2954
  for(var i=0;i<N;i++){var d=D[i],tx=d.tx,ty=d.ty,tr=d.gone?0:d.r;
2937
2955
  if(rest){tx=cx+Math.cos(i*1.3)*16;ty=cy+Math.sin(i*1.3)*16;tr=d.r*0.6;}
2938
2956
  else if(pool){tx=cx+(i-(N-1)/2)*40*S.s;ty=S.h-40;tr=13*S.s;}
2939
- else if(talk&&!d.blob){var t0=(LQ.scene!=='work'&&LQ.top)||S.h*0.12;ty=t0+(d.ty-t0)*0.55;}
2957
+ else if(talk&&!d.blob&&!d.band){var t0=(LQ.scene!=='work'&&LQ.top)||S.h*0.12;ty=t0+(d.ty-t0)*0.55;}
2940
2958
  var ax=k*(tx-d.x)-c*d.vx,ay=k*(ty-d.y)-c*d.vy;
2941
2959
  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;}
2942
- if(!rest&&!pool){d.x=Math.max(d.cr+12,Math.min(S.w-d.cr-12,d.x));d.y=Math.max((d.blob?60:140)+d.cr,Math.min(S.h-150-d.cr-50,d.y));}
2960
+ if(!rest&&!pool){d.x=Math.max(d.cr+12,Math.min(S.w-d.cr-12,d.x));d.y=Math.max((d.blob||d.band?60:140)+d.cr,Math.min(S.h-150-d.cr-50,d.y));}
2943
2961
  d.cr+=(tr-d.cr)*(still?1:0.06);
2944
- var b=LQ.btns[i];if(b&&b.offsetWidth)d.lw=b.offsetWidth;if(b&&b.style){b.style.transform=d.blob?'translate('+Math.round(d.x+d.r+30)+'px,'+Math.round(d.y-d.r-4)+'px)':'translate('+Math.round(d.x-(b.offsetWidth||0)/2)+'px,'+Math.round(d.y+d.cr+(d.kind==='run'?28:4))+'px)';var show=!rest&&!pool;b.style.opacity=show?'1':'0';b.style.pointerEvents=show?'auto':'none';var lt=LQ.lts&&LQ.lts[i];if(lt)lt.tabIndex=show?0:-1;}}
2945
- (LQ.gbtns||[]).forEach(function(gb){var g=gb.getAttribute('data-g'),top=1e9,sx=0,nn=0;D.forEach(function(d){if(d.kind!=='more'&&lqPara(d)===g){top=Math.min(top,d.y-d.cr);sx+=d.x;nn++;}});if(!nn||!gb.style)return;var gw=gb.offsetWidth||0,gh=gb.offsetHeight||0,zt=LQ.scene!=='work'&&LQ.zoneT&&LQ.zoneT[g];if(zt)gb.style.transform='translate('+Math.round(zt[0]-gw/2)+'px,'+Math.round(zt[1]-gh/2)+'px)';else{var gx=Math.max(12,Math.min(lqSize().w-gw-12,sx/nn-gw/2)),gy=top-gh-30;
2962
+ var b=LQ.btns[i];if(b&&b.offsetWidth)d.lw=b.offsetWidth;if(b&&b.style){b.style.transform=d.blob?'translate('+Math.round(d.x+2*d.r+22)+'px,'+Math.round(d.y-d.r-4)+'px)':d.band?'translate('+Math.round(d.x+d.r+30)+'px,'+Math.round(d.y-(b.offsetHeight||36)/2)+'px)':'translate('+Math.round(d.x-(b.offsetWidth||0)/2)+'px,'+Math.round(d.y+d.cr+(d.kind==='run'?28:4))+'px)';var show=!rest&&!pool;b.style.opacity=show?'1':'0';b.style.pointerEvents=show?'auto':'none';var lt=LQ.lts&&LQ.lts[i];if(lt)lt.tabIndex=show?0:-1;}}
2963
+ var gplaced=[];(LQ.gbtns||[]).forEach(function(gb){var g=gb.getAttribute('data-g'),top=1e9,sx=0,nn=0;D.forEach(function(d){if(d.kind!=='more'&&lqPara(d)===g){top=Math.min(top,d.y-d.cr);sx+=d.x;nn++;}});if(!nn||!gb.style)return;var gw=gb.offsetWidth||0,gh=gb.offsetHeight||0,zt=LQ.scene!=='work'&&LQ.zoneT&&LQ.zoneT[g];if(zt){gb.style.transform='translate('+Math.round(zt[0]-gw/2)+'px,'+Math.round(zt[1]-gh/2)+'px)';gplaced.push([zt[0]-gw/2,zt[1]-gh/2,zt[0]+gw/2,zt[1]+gh/2]);}else{var gx=Math.max(12,Math.min(lqSize().w-gw-12,sx/nn-gw/2)),gy=top-gh-30;
2946
2964
  // off any droplet it would sit on (the "more" droplet, another group's): above it instead
2947
2965
  for(var pass=0;pass<3;pass++)D.forEach(function(d){if(d.blob||lqPara(d)===g&&d.kind!=='more')return;var l=d.x-d.cr-6,r=d.x+d.cr+6,t=d.y-d.cr-6,b=d.y+d.cr+(d.lw?24:6);if(gx<r&&gx+gw>l&&gy<b&&gy+gh>t)gy=t-gh-4;});
2948
- gb.style.transform='translate('+Math.round(gx)+'px,'+Math.round(Math.max(LQ.scene!=='work'&&LQ.top?LQ.top:48,gy))+'px)';}var gshow=!rest&&!pool&&!talk;gb.style.opacity=gshow?'1':'0';gb.style.pointerEvents=gshow?'auto':'none';gb.tabIndex=gshow?0:-1;});
2966
+ gy=Math.max(LQ.scene!=='work'&&LQ.top?LQ.top:48,gy);
2967
+ // nor on another group's heading ("Watching" was drawn over "Areas"): beside it instead, on the side its own group is
2968
+ for(var ps=0;ps<3;ps++)gplaced.forEach(function(p){if(gx<p[2]+6&&gx+gw>p[0]-6&&gy<p[3]+4&&gy+gh>p[1]-4)gx=gx+gw/2<(p[0]+p[2])/2?p[0]-gw-12:p[2]+12;});gx=Math.max(12,Math.min(lqSize().w-gw-12,gx));
2969
+ gplaced.push([gx,gy,gx+gw,gy+gh]);gb.style.transform='translate('+Math.round(gx)+'px,'+Math.round(gy)+'px)';}var gshow=!rest&&!pool&&!talk;gb.style.opacity=gshow?'1':'0';gb.style.pointerEvents=gshow?'auto':'none';gb.tabIndex=gshow?0:-1;});
2949
2970
  var ntl=$('lqneedt');if(ntl&&ntl.style)ntl.style.opacity=rest?'0':'1';var nxb=$('lqnext');if(nxb&&nxb.style){nxb.style.opacity=rest?'0':'';nxb.style.pointerEvents=rest?'none':'';}
2950
2971
  (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';});
2951
2972
  var cb=$('lqcore');if(cb&&cb.style){var inC=core.cr>60;if(cb.classList)cb.classList.toggle('in',inC&&!rest);cb.style.maxWidth=inC?Math.round(core.cr*1.5)+'px':'340px';var cw=cb.offsetWidth||0,chh=cb.offsetHeight||0;
@@ -3023,11 +3044,12 @@ LQ.drops.forEach(function(dr){put(k++,dr.x,dr.y,dr.cr,dr.kind==='you'||dr.ask?1:
3023
3044
  // a tendril is a chain of blobs close enough to always bridge (spaced at under 2.8r),
3024
3045
  // never one blob floating in the gap: drawn whole or not at all
3025
3046
  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>NB-1)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);}});
3026
- // the liquid line from a lit orb to its blob: three drops close enough to always
3047
+ // the liquid line from a lit orb to its blob (which sits 2r+22 right of it, clear of the orb): four drops close enough to always
3027
3048
  // bridge, the last under the blob's edge, so the blob hangs off the orb; drawn whole or not at all
3028
- if(LQ.mode!=='rest'&&LQ.mode!=='pool')LQ.drops.forEach(function(dr){if(!dr.blob||dr.gone||dr.cr<6||k+3>NB-1)return;var x1=dr.x+dr.cr;put(k++,x1+8,dr.y,7*Math.min(1,dr.cr/dr.r),1,0.2);put(k++,x1+18,dr.y,6,1,0.2);put(k++,x1+31,dr.y,10,1,0.2);});
3049
+ if(LQ.mode!=='rest'&&LQ.mode!=='pool')LQ.drops.forEach(function(dr){if(!dr.blob||dr.gone||dr.cr<6||k+4>NB-1)return;var x1=dr.x+dr.cr+8,x2=dr.x+2*dr.r+28,st=(x2-x1)/3;put(k++,x1,dr.y,7*Math.min(1,dr.cr/dr.r),1,0.2);put(k++,x1+st,dr.y,6,1,0.2);put(k++,x1+2*st,dr.y,6,1,0.2);put(k++,x2,dr.y,10,1,0.2);});
3029
3050
  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;}});
3030
- 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(NB-1,best.x+dx/dl*reach,best.y+dy/dl*reach,8+20*(1-Math.max(0,bd)/230));}}
3051
+ // a bulge toward the pointer, its tip always on the orb's edge: reaching further, it hung in the gap as an orb of its own with no label (2026-10-08)
3052
+ if(best&&bd<230){var dx=P[0]-best.x,dy=P[1]-best.y,dl=Math.hypot(dx,dy)||1,tr=8+20*(1-Math.max(0,bd)/230),reach=Math.min(dl,best.cr+tr*0.6);put(NB-1,best.x+dx/dl*reach,best.y+dy/dl*reach,tr);}}
3031
3053
  // drawn seldom (no GPU, or less motion asked for): still, and only when something moved
3032
3054
  if(slow){var sig=W+'|'+H+'|'+th.look+th.night+th.contrast;for(var q=0;q<o.length;q++)sig+=','+Math.round(o[q]*400);if(sig===G.sig)return;G.sig=sig;}
3033
3055
  var U=G.U;gl.uniform2f(U.uRes,W,H);gl.uniform1f(U.uT,slow?0:t);gl.uniform1f(U.uDpr,d);gl.uniform4fv(U.uB,o);gl.uniform3f(U.uRip,LQ.ripple[0],LQ.ripple[1],still?-10:LQ.ripple[2]);
@@ -3052,7 +3074,7 @@ function awShort(t){t=String(t||'');var cut=[' - ',' \u00b7 ',' | '];cut.forEach
3052
3074
  function awStatus(){var el=$('awstat');if(!el)return;Promise.all([api('/api/home').catch(function(){return {};}),api('/api/work').catch(function(){return {};})]).then(function(r){var h=r[0]||{},w=r[1]||{},out=[];
3053
3075
  var runs=(w.running||[]).slice(0,4),nr=(w.running||[]).length||h.working||0;
3054
3076
  out.push(nr?'<b>'+nr+' agent'+(nr===1?'':'s')+' at work</b>':'No agent at work');
3055
- runs.forEach(function(x){out.push("<span class='awr'>"+esc(x.lane||x.name||'')+': '+esc(x.doing||'working on it')+'</span>');});
3077
+ runs.forEach(function(x){out.push("<span class='awr'>"+esc(x.lane||x.name||'')+': '+esc(runWarn(x)+(x.line||x.doing||'working on it')+(x.eta?' · '+x.eta:''))+'</span>');});
3056
3078
  var y=h.youCount||0;out.push(y?'<b>'+y+' thing'+(y===1?'':'s')+' only you can do</b>':'Nothing needs you');
3057
3079
  var fd=(h.feeds||[]).filter(function(f){return f.count;}).slice(0,4).map(function(f){return esc(awShort(f.title))+' '+f.count;});if(fd.length)out.push('Waiting: '+fd.join(' \u00b7 '));
3058
3080
  el.innerHTML=out.join('<br>');}).catch(function(){});}
package/work.mjs CHANGED
@@ -52,15 +52,17 @@ function parseRun(text) {
52
52
  for (const l of lines) { if (l.startsWith("{")) { try { events.push(JSON.parse(l)); continue; } catch {} } if (l.trim()) plain.push(l); }
53
53
  const out = { stream: events.length > 0, model: "", steps: [], todos: [], doing: "", said: [], final: "", cost: null, turns: null, tokens: null, tests: null, files: {}, pace: [], errors: 0 };
54
54
  if (!out.stream) { out.said = plain.slice(-12).map((l) => short(l, 160)); out.final = plain.slice(-40).join("\n").trim(); return out; }
55
- const byId = new Map(), tasks = new Map(); let thinking = false;
55
+ const byId = new Map(), tasks = new Map(), talk = []; let thinking = false, moved = 0;
56
56
  for (const e of events) {
57
57
  const at = e.timestamp ? Date.parse(e.timestamp) : null;
58
+ // a move: something it said or did, or a step coming back; not a heartbeat
59
+ if (at && (e.type === "assistant" || e.type === "user")) moved = Math.max(moved, at);
58
60
  if (e.session_id && !out.session) out.session = String(e.session_id);
59
61
  if (e.type === "system" && e.subtype === "init") { out.model = String(e.model || ""); continue; }
60
62
  if (e.type === "assistant" && e.message && Array.isArray(e.message.content)) {
61
63
  for (const b of e.message.content) {
62
64
  if (b.type === "thinking") { thinking = true; continue; }
63
- if (b.type === "text" && b.text && b.text.trim()) { out.said.push(short(b.text, 220)); thinking = false; continue; }
65
+ if (b.type === "text" && b.text && b.text.trim()) { out.said.push(short(b.text, 220)); talk.push(short(b.text, 400)); thinking = false; continue; }
64
66
  if (b.type !== "tool_use") continue;
65
67
  thinking = false;
66
68
  const inp = b.input || {};
@@ -102,9 +104,78 @@ function parseRun(text) {
102
104
  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))]++; }
103
105
  out.steps = out.steps.slice(-MAX_STEPS).map(({ at, ...s }) => ({ ...s, ...(at ? { at } : {}) }));
104
106
  out.said = out.said.slice(-6);
107
+ out.talk = talk.slice(-120); out.moved = moved || null;
108
+ // going round: its last LOOP steps all the same call
109
+ const tail = out.steps.slice(-LOOP), k = (x) => x.verb + " " + x.target;
110
+ out.looping = tail.length === LOOP && !out.final && tail.every((x) => k(x) === k(tail[0]));
105
111
  return out;
106
112
  }
107
113
 
114
+ // ---- how far along a run is ---------------------------------------------------------
115
+ // Home said only "Agents working: 2" while steve and argena had each been at it for
116
+ // half an hour (2026-10-08): nothing said what either was doing or how far along.
117
+ // A brief's plan is the numbered steps of the first open task in its TASKS.md
118
+ // ("1) Device pass on v6.7. … 2) Look and feel, plus a zoned world. …"), each as a
119
+ // short label; where the run is in it comes from what the agent says as it goes
120
+ // ("Device pass done … moving to step 2", "Now comparing item by item…").
121
+ const LOOP = 5;
122
+ const STOP = new Set("against about after again also because been before being both but can could did does doing done each else even every first from have here into just like make more most much must next now once only other over same should since some sure than that the their them then there these they this those through what when where which while will with without would your you yours step steps check checks checked".split(" "));
123
+ const stem = (w) => w.toLowerCase().replace(/(ings?|ed|es|s|e)$/, "").slice(0, 6);
124
+ const stems = (t) => new Set(String(t || "").toLowerCase().match(/[a-z][a-z'-]{3,}/g)?.filter((w) => !STOP.has(w)).map(stem) || []);
125
+ // The numbered steps of a brief's first open task → ["Device pass on v6.7", …]; [] when it has none.
126
+ function briefPlan(md) {
127
+ const line = String(md || "").split("\n").find((l) => /^\s*-\s*\[ \]/.test(l));
128
+ if (!line) return [];
129
+ const t = line.replace(/^\s*-\s*\[ \]\s*/, ""), cuts = [];
130
+ for (const m of t.matchAll(/(^|[\s.:;])(\d{1,2})[).]\s+/g)) { if (+m[2] === cuts.length + 1) cuts.push({ n: +m[2], at: m.index + m[1].length, body: m.index + m[0].length }); }
131
+ if (cuts.length < 2) return [];
132
+ return cuts.map((c, i) => {
133
+ const full = t.slice(c.body, i + 1 < cuts.length ? cuts[i + 1].at : undefined).trim();
134
+ let label = full.split(/[.;:!?](?:\s|$)|\s\(|\s[—–-]\s/)[0].trim();
135
+ const comma = label.indexOf(", ", 20); if (comma > 0) label = label.slice(0, comma);
136
+ return { label: short(label, 70), full: full.split(/\s+/).slice(0, 30).join(" ") };
137
+ });
138
+ }
139
+ // Which step (1-based) of plan the run is on, from what it said, in order: "step 2"
140
+ // names it ("step 1 done" means the next; every brief asks for "Step 2: …"); otherwise
141
+ // two words of a step's label in what it said. Fewer, or the step's other words, would
142
+ // be too loose: "wiring the map" isn't step 3's "wire the class bible", nor "the
143
+ // monsters" step 4 because step 4 mentions monsters. Only forward. 0: can't tell.
144
+ function stepOf(plan, talk) {
145
+ if (!plan || !plan.length) return 0;
146
+ const L = plan.map((p) => [...stems(p.label)]);
147
+ let cur = 0;
148
+ for (const text of talk || []) {
149
+ let named = 0;
150
+ for (const m of String(text).matchAll(/\bstep\s+(\d{1,2})\b([^.\n]{0,40})/gi)) { const n = +m[1] + (/^\W*(?:\([^)]*\)\W*)?(?:is\s+|are\s+)?(?:done|finished|complete)/i.test(m[2]) ? 1 : 0); if (n <= plan.length) named = Math.max(named, n); }
151
+ if (named) { cur = Math.max(cur, named); continue; }
152
+ const said = stems(text), hits = L.map((l) => l.filter((w) => said.has(w)).length);
153
+ const top = Math.max(...hits), at = hits.indexOf(top);
154
+ if (top >= 2 && hits.lastIndexOf(top) === at) cur = Math.max(cur, at + 1);
155
+ }
156
+ return Math.min(cur, plan.length);
157
+ }
158
+ // What it's on, in a line: its in-progress to-do, else the last thing it said it's
159
+ // doing (the last sentence of what it said last), else its last step.
160
+ function nowLine(w) {
161
+ const todo = (w.todos || []).find((t) => t.status === "in_progress");
162
+ if (todo) return todo.active;
163
+ const last = (w.talk || [])[(w.talk || []).length - 1];
164
+ if (last) { const ss = String(last).replace(/\s+/g, " ").trim().split(/(?<=[.!?])\s+/); const s = (ss.filter((x) => x.trim().length > 12).pop() || ss.pop() || "").trim().replace(/[.:]$/, ""); if (s) return s; }
165
+ return w.doing || "";
166
+ }
167
+ // A running agent in one line for Home: "step 2 of 4: look and feel, plus a zoned world
168
+ // · shooting the monsters"; quiet: minutes since its last move, once that's past
169
+ // STALL_MS (it may be stuck, or one long command), looping: its last steps all alike.
170
+ const STALL_MS = 20 * 60 * 1000;
171
+ function runLine({ plan = [], step = 0, work = {}, now = Date.now() } = {}) {
172
+ const n = short(nowLine(work), 90), lower = (s) => s.charAt(0).toLowerCase() + s.slice(1);
173
+ const at = step && plan[step - 1] ? `step ${step} of ${plan.length}: ${lower(plan[step - 1].label)}` : plan.length ? `${plan.length} steps in its brief` : "";
174
+ const line = [at, n && lower(n)].filter(Boolean).join(" · ") || "working on it";
175
+ const quiet = work.moved && now - work.moved > STALL_MS ? Math.round((now - work.moved) / 60000) : 0;
176
+ return { line, ...(step ? { step, of: plan.length } : {}), ...(quiet ? { quiet } : {}), ...(work.looping ? { looping: true } : {}) };
177
+ }
178
+
108
179
  // The newest run of an agent.log, from its "=== " header on, read from the end of the
109
180
  // file. A log keeps every run and everything they read (one reached 144 MB: 96 runs and
110
181
  // the screenshots they looked at), and every view only needs the newest, so it never
@@ -136,4 +207,4 @@ function lastRunText(log) {
136
207
  // What the agent said last: its final answer, from either kind of log.
137
208
  function finalOf(log) { return parseRun(lastRunText(log)).final; }
138
209
 
139
- export { parseRun, lastRunText, finalOf, describe, testsIn, readRunLog };
210
+ export { parseRun, lastRunText, finalOf, describe, testsIn, readRunLog, briefPlan, stepOf, nowLine, runLine, STALL_MS };