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 +6 -0
- package/agents.mjs +16 -9
- package/estimate.mjs +109 -0
- package/home.mjs +24 -5
- package/package.json +3 -2
- package/tasks.mjs +1 -1
- package/ui.mjs +10 -7
- package/work.mjs +74 -3
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
|
|
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
|
|
1064
|
-
|
|
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}
|
|
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.
|
|
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?' · '+
|
|
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 ›</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 ›</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 };
|