symbiot 0.57.2 → 0.57.3

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,12 @@
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.3
6
+
7
+ - **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.
8
+ - **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.
9
+ - **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)".
10
+
5
11
  ## 0.57.2
6
12
 
7
13
  - **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.3",
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
@@ -2552,8 +2552,8 @@ function lqRedraw(){whenFree($('liquid'),'liquid',function(){if(LQ.scene==='work
2552
2552
  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
2553
  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
2554
  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});});
2555
+ 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':'';
2556
+ 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
2557
  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
2558
  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
2559
  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 +2581,7 @@ function lqBuildProjects(){var w=LQ.workData||{},S=lqSize(),cx=S.w/2,cy=S.h*0.5,
2581
2581
  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
2582
  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
2583
  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;
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':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
2585
  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
2586
  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
2587
 
@@ -2683,6 +2683,7 @@ var gs=Object.keys(groups).map(function(k){var g=groups[k];return {key:k,m:g,ang
2683
2683
  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
2684
  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
2685
  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;});});}
2686
+ function runWarn(x){return x.quiet?'no progress in '+x.quiet+' min · ':x.looping?'repeating the same step · ':'';}
2686
2687
  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
2688
  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
2689
  else if((d.kind==='you'||d.kind==='feed')&&at[d.shape]!=null)links.push([at[d.shape],i,0.5]);});return links.filter(same);}
@@ -2702,6 +2703,8 @@ lay.slice(room).forEach(function(it){if(!vis.some(function(x){return x.id===it.i
2702
2703
  var mkh=h.marketing||{},mkSub=mkh.needs?mkh.needs+(mkh.needs>1?' need you':' needs you'):mkh.working?'its agent is at work':'';
2703
2704
  lay.slice(0,room).forEach(function(it){var mko=it.id==='marketing';list.push({id:'shape:'+it.id,kind:'shape',shape:it.id,title:LQNAMES[it.id]||it.id,sub:it.id==='reports'&&repOut?repNew.count+' new':mko?mkSub:'',ask:mko&&!!mkh.needs,orbit:mko&&!!mkh.working,r:it.r*rs,tx:cx+Math.cos(it.angle)*it.d*vx,ty:cy+Math.sin(it.angle)*it.d*vy});});
2704
2705
  feeds.forEach(function(f,i){var ang=Math.PI/2+(i-(feeds.length-1)/2)*0.7;var fn=bsName({name:f.title});list.push({id:f.id,kind:'feed',shape:f.shape,title:fn.n+(fn.sub?' · '+fn.sub.split('@').pop():''),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});});
2706
+ // each agent at work, a drop of its own by the Workdesk: its lane, where it is in its brief ("step 2 of 4: …"), what it's on and how long it has left, amber once it has gone quiet or round in circles
2707
+ (h.runs||[]).slice(0,3).forEach(function(x,i,a){var ang=PARA_ANG.p+(i-(a.length-1)/2)*0.45;list.push({id:x.id,kind:'run',shape:'agents',repo:x.repo,title:x.name,sub:runWarn(x)+x.line+(x.eta?' · '+x.eta:''),ask:!!(x.quiet||x.looping),r:30*rs,tx:cx+Math.cos(ang)*330*vx,ty:cy+Math.sin(ang)*300*vy});});
2705
2708
  list.push({id:'archive',kind:'archive',shape:'tasks',title:'Archive',sub:'what’s done: archived tasks',r:26*rs,tx:cx+Math.cos(PARA_ANG.x)*330*vx,ty:cy+Math.sin(PARA_ANG.x)*330*vy});
2706
2709
  list.forEach(function(d){var g=lqPara(d);d.live=PARA_LIVE[g];if(g==='r')d.r*=0.82;if(g==='x')d.r*=0.85;if(d.shape==='tasks'&&d.kind==='shape'&&h.working)d.orbit=true;});
2707
2710
  var mr=more.length?[{id:'more',kind:'more',title:'more',sub:more.map(function(m){return LQNAMES[m]||m;}).join(' · '),more:more.slice(),r:24*S.s}]:[],zl=list.concat(mr),zc=lqZones(zl,S,top,92*S.s);
@@ -2718,8 +2721,8 @@ list=cur;}
2718
2721
  list=list.concat(need.items);var old={};LQ.drops.forEach(function(d){old[d.id]=d;});
2719
2722
  // a new blob grows where it belongs, rather than sweeping out of the core across the rest
2720
2723
  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');
2724
+ 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)':''):'';
2725
+ 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
2726
  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
2727
  LQ.talkWeight=(a.modes&&a.modes.talkWeight)||0.35;
2725
2728
  lqLabels();}
@@ -2784,7 +2787,7 @@ while(out.indexOf(' ')>=0)out=out.split(' ').join(' ');[' :',' ,',' ;',' .'].f
2784
2787
  function lqLabels(){var el=$('lqdrops');if(!el)return;
2785
2788
  var dropAt=function(b){return LQ.drops[+b.getAttribute('data-i')];};
2786
2789
  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(''));
2790
+ 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.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
2791
  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
2792
  if(lt)lt.addEventListener('click',function(ev){lqOpen(dropAt(b),ev);});
2790
2793
  if(go)go.addEventListener('click',function(ev){LQ.openTag=null;lqOpen(dropAt(b),ev);});
@@ -3052,7 +3055,7 @@ function awShort(t){t=String(t||'');var cut=[' - ',' \u00b7 ',' | '];cut.forEach
3052
3055
  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
3056
  var runs=(w.running||[]).slice(0,4),nr=(w.running||[]).length||h.working||0;
3054
3057
  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>');});
3058
+ 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
3059
  var y=h.youCount||0;out.push(y?'<b>'+y+' thing'+(y===1?'':'s')+' only you can do</b>':'Nothing needs you');
3057
3060
  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
3061
  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 };