symbiot 0.57.4 → 0.57.6
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 +20 -0
- package/README.md +41 -9
- package/agents.mjs +116 -8
- package/handover.mjs +86 -4
- package/home.mjs +226 -25
- package/index.mjs +7 -11
- package/lanes.mjs +49 -6
- package/marketing.mjs +110 -8
- package/mind.mjs +7 -3
- package/package.json +2 -2
- package/post.mjs +41 -17
- package/reports.mjs +102 -20
- package/server.mjs +39 -14
- package/tasks.mjs +96 -29
- package/ui.mjs +107 -39
- package/watch.mjs +110 -11
package/home.mjs
CHANGED
|
@@ -12,22 +12,22 @@
|
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
15
|
-
import { watchBoard, draftReply } from "./watch.mjs";
|
|
15
|
+
import { watchBoard, draftReply, draftCards, draftAnswer } from "./watch.mjs";
|
|
16
16
|
import { pendingReview, pushTasks, addTask, taskType } from "./tasks.mjs";
|
|
17
17
|
import { estimate, estimateWords } from "./estimate.mjs";
|
|
18
|
-
import { agentsList, runHandoff, runningHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent, detectHandoffs, linkReach } from "./agents.mjs";
|
|
18
|
+
import { setHandoffCmd, agentsList, runHandoff, runningHandoff, handoffCmd, loadRuns, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent, detectHandoffs, linkReach } from "./agents.mjs";
|
|
19
19
|
import { linksState } from "./links.mjs";
|
|
20
20
|
import { knowledgeState } from "./knowledge.mjs";
|
|
21
|
-
import { CONFIG_DIR, loadTasks, saveTasks, loadConfig, saveConfig } from "./core.mjs";
|
|
21
|
+
import { CONFIG_DIR, loadTasks, saveTasks, loadConfig, saveConfig, sameTask } from "./core.mjs";
|
|
22
22
|
import { lanesState, stuckHandovers, allowHandover, skipHandover } from "./lanes.mjs";
|
|
23
23
|
import { converse, actIn, actNow, taskIn, loadMind } from "./mind.mjs";
|
|
24
24
|
import { repoPathMap, laneMap, reposState, scanRoots } from "./scan.mjs";
|
|
25
25
|
import { resolveProvider, PROVIDERS, claudeState } from "./ai.mjs";
|
|
26
|
-
import { reportsNews, listReports } from "./reports.mjs";
|
|
26
|
+
import { reportsNews, listReports, readReport, decide } from "./reports.mjs";
|
|
27
27
|
import { awaitingState, inboxSight } from "./handback.mjs";
|
|
28
28
|
import { newClashes } from "./checks.mjs";
|
|
29
29
|
import { postsState } from "./post.mjs";
|
|
30
|
-
import { MARKETING, MARKETING_DIR, MARKETING_WORDS, displayName, productNames, productOf, untagged, tagged, draftFiles } from "./marketing.mjs";
|
|
30
|
+
import { MARKETING, MARKETING_DIR, MARKETING_WORDS, displayName, productNames, productOf, untagged, tagged, draftFiles, setDraftStatus } from "./marketing.mjs";
|
|
31
31
|
|
|
32
32
|
const KEEP = 15000; // the slower reads (git per repo awaiting review) are cached this long
|
|
33
33
|
let cached = null;
|
|
@@ -47,8 +47,16 @@ const firstLine = (t) => String(t || "").split("\n").find((l) => l.trim()) || ""
|
|
|
47
47
|
// a step your answer left you) with what to check first, and a run that ended in an
|
|
48
48
|
// error. Each is answered on its blob (homeAnswer), on Home and on its lane in
|
|
49
49
|
// Tasks. repo: its lane ("" for an ops run).
|
|
50
|
-
function troubles({ stuck = [], list = [], map = {}, named = displayName, now = Date.now() } = {}) {
|
|
50
|
+
function troubles({ stuck = [], list = [], map = {}, named = displayName, now = Date.now(), drafts = [] } = {}) {
|
|
51
51
|
const out = [], seen = new Set();
|
|
52
|
+
// a drafted reply: the reply itself on the card, as it will look, and Go ahead sends it (watch.mjs draftCard)
|
|
53
|
+
for (const c of drafts) {
|
|
54
|
+
seen.add(c.dir);
|
|
55
|
+
const to = c.to ? ` to ${c.to}` : "";
|
|
56
|
+
out.push({ kind: "ask", fix: "draft", id: "draft:" + c.id, path: c.dir, repo: "", name: `Reply${to} on ${c.platform}`, title: `Reply${to} on ${c.platform}`, sub: plain(c.text, 90),
|
|
57
|
+
q: `Go ahead sends it${to} from your ${c.platform}, signed in in Symbiot's browser, exactly as it shows here.`, options: ["Go ahead (recommended)", "Skip"],
|
|
58
|
+
draft: { platform: c.platform, kind: c.kind, to: c.to, text: c.text }, shape: "agents" });
|
|
59
|
+
}
|
|
52
60
|
for (const t of stuck) {
|
|
53
61
|
const lane = t.from.lane === RUNS_LANE ? "" : t.from.lane;
|
|
54
62
|
out.push({ kind: "ask", fix: "handover", id: "stuck:" + t.id, path: lane ? map[lane] || t.from.path : t.from.path, repo: lane, name: lane ? named(map[lane], lane) : "Agent runs",
|
|
@@ -73,6 +81,41 @@ function troubles({ stuck = [], list = [], map = {}, named = displayName, now =
|
|
|
73
81
|
return out;
|
|
74
82
|
}
|
|
75
83
|
|
|
84
|
+
// Where your repos are is a fact on this disk, not a thing only you know: Symbiot
|
|
85
|
+
// finds them itself (scan.mjs, on start and every few minutes) and never asks for it.
|
|
86
|
+
// "Show me your work: where your repos are" asked anyway whenever the list was empty,
|
|
87
|
+
// and it was empty while the first search ran and whenever one was cut short. Now:
|
|
88
|
+
// once it has found them, you're asked once to confirm (or correct, in your own
|
|
89
|
+
// words: an agent fixes it); and for a folder only when a finished search found none.
|
|
90
|
+
// Folders you set in Settings (scanRoots) are your answer already: the list isn't
|
|
91
|
+
// asked about again, and a search that finds nothing in them is Symbiot's to retry
|
|
92
|
+
// (Settings says so under the folders), never a card asking where your work is.
|
|
93
|
+
function reposCard({ map = {}, search = {}, confirmed = false, rootsSet = false } = {}) {
|
|
94
|
+
const found = Object.keys(map).filter((n) => map[n] !== MARKETING_DIR);
|
|
95
|
+
if (found.length) {
|
|
96
|
+
if (confirmed || rootsSet) return [];
|
|
97
|
+
const list = found.slice(0, 8).join(", ") + (found.length > 8 ? ` and ${found.length - 8} more` : "");
|
|
98
|
+
return [{ kind: "ask", fix: "repos", id: "repos:confirm", path: "", repo: "", name: "Your projects", title: `I found ${found.length} project${found.length > 1 ? "s" : ""}`, sub: plain(list, 90),
|
|
99
|
+
q: `I found these on this computer: ${list}. Is that all of them? If one's missing or shouldn't be there, say which.`, options: ["Looks right (recommended)", "Skip"], shape: "settings" }];
|
|
100
|
+
}
|
|
101
|
+
if (search.searching || !search.at || rootsSet) return []; // still looking, or you've said where: nothing to ask
|
|
102
|
+
return [{ kind: "setup", id: "setup:folders", title: "No projects found", sub: "I searched your folders and found no repos: add the folder they're in", shape: "settings", focus: "work" }];
|
|
103
|
+
}
|
|
104
|
+
const SAYS_YES = /^\s*(yes|yep|yeah|ok|okay|looks (right|good)|correct|right|all good|that'?s (all|it|right))\b[\s.!]*$/i;
|
|
105
|
+
// Your answer on it: right (or skipped) settles it; anything else is a correction an
|
|
106
|
+
// agent makes (adds the folder, says why one is missing), not a chore for you.
|
|
107
|
+
function reposAnswer({ pick, text = "" } = {}, { act = actNow, repos = laneMap } = {}) {
|
|
108
|
+
const skip = /^\s*(skip|no|nope|never ?mind|leave it)[\s.!]*$/i.test(text); // a whole "no", not "no, ~/work is missing"
|
|
109
|
+
const fix = text && !SAYS_YES.test(text) && !skip;
|
|
110
|
+
const cfg = loadConfig(); cfg.reposConfirmed = { at: Date.now(), ...(fix ? { fix: text } : {}) }; saveConfig(cfg);
|
|
111
|
+
if (!fix) return { ok: true, said: Number(pick) === 1 || skip ? "Skipped. Change the folders any time in Settings." : "Good. New ones are picked up as they appear." };
|
|
112
|
+
let found = {}; try { found = repos() || {}; } catch {}
|
|
113
|
+
const list = Object.entries(found).filter(([, p]) => p !== MARKETING_DIR).map(([n, p]) => `- ${n}: ${p}`).join("\n");
|
|
114
|
+
const r = act(`Correct the list of projects Symbiot found on this computer. The user said: "${text}"`, { title: "fix the project list",
|
|
115
|
+
context: `Symbiot scans ${scanRoots().join(", ")} for git repos (scan folders: Settings, or scanRoots in ~/.config/symbiot/config.json). It found:\n${list}\nFind what the user means on disk yourself; don't ask them where things are.` });
|
|
116
|
+
return r.error ? { error: r.error } : { ok: true, said: "An agent is fixing the list from what you said." };
|
|
117
|
+
}
|
|
118
|
+
|
|
76
119
|
// ---- next up: what can be done when nothing waits on you ----------------------------
|
|
77
120
|
// With "Only the user can do" empty, Home suggests a few things that CAN be done next,
|
|
78
121
|
// each with its gain spelled out and one tap that starts an agent or makes a task
|
|
@@ -203,11 +246,14 @@ function marketingState({ deps = {}, list, pend, map, named = displayName } = {}
|
|
|
203
246
|
const ready = (pend || tryOr(deps.pending || pendingReview, []) || []).find((r) => r.repo === MARKETING && r.path && !r.running && ((r.tasks || []).length || (r.files || []).length));
|
|
204
247
|
if (ready) { const n = (ready.tasks || []).length, f = (ready.files || []).length; needs.push({ kind: "approve", id: "approve", text: `${n ? `${n} piece${n > 1 ? "s" : ""} of marketing done` : "Marketing's agent changed things without a task"}${f ? ` · ${f} file${f > 1 ? "s" : ""}` : ""}: only you decide`, count: n || f }); }
|
|
205
248
|
for (const p of (posts.posts || []).filter((x) => x.status === "waiting")) needs.push({ kind: "draft", id: "draft:" + p.id, text: plain(firstLine(p.text), 120), product: productOf(p.text, names) });
|
|
249
|
+
// a post its agent drafted that you haven't approved or skipped: approving its preview is
|
|
250
|
+
// the only step a post needs from you, so it's lit here (and on Home's orb), Preview one click away
|
|
251
|
+
for (const f of files.filter((x) => !x.status)) needs.push({ kind: "post", id: "post:" + f.rel, rel: f.rel, text: plain(f.name, 120), product: f.product });
|
|
206
252
|
const lane = tasks.filter((t) => t.repo === MARKETING).map((t) => ({ id: t.id, text: plain(untagged(t.text), 220), product: productOf(t.text, names), status: t.review ? "review" : working ? "working" : "waiting", ts: t.ts || 0 }));
|
|
207
253
|
const elsewhere = tasks.filter((t) => t.repo !== MARKETING && MARKETING_WORDS.test(t.text)).slice(0, 12).map((t) => ({ id: t.id, text: plain(untagged(t.text), 220), product: productOf(t.text, names), repo: t.repo || "", name: t.repo ? named(map[t.repo], t.repo) : "" }));
|
|
208
254
|
const seen = [...new Set([...lane, ...elsewhere, ...needs, ...files].map((x) => x.product).filter(Boolean))];
|
|
209
255
|
const on = !!(posts.canDraft || (posts.posts || []).length || (posts.done || []).length || lane.length || elsewhere.length || files.length || needs.length || run);
|
|
210
|
-
return { on, needs, needCount: needs.length, lane, elsewhere, files: files.map(({ rel, name, product, at }) => ({ rel, name, product, at })), products: [...new Set([...seen, ...names])], working, waiting: lane.filter((t) => t.status === "waiting").length };
|
|
256
|
+
return { on, needs, needCount: needs.length, lane, elsewhere, files: files.map(({ rel, name, product, at, status }) => ({ rel, name, product, at, status })), products: [...new Set([...seen, ...names])], working, waiting: lane.filter((t) => t.status === "waiting").length };
|
|
211
257
|
}
|
|
212
258
|
// A task for Marketing, tagged with the product it markets (the page's Add). The task or { error }.
|
|
213
259
|
function marketingTask(text, product = "", { add = addTask } = {}) {
|
|
@@ -224,6 +270,20 @@ function moveToMarketing(id, product = "", { load = loadTasks, save = saveTasks,
|
|
|
224
270
|
t.repo = MARKETING; save(all); cached = null;
|
|
225
271
|
return { ok: true, id: t.id };
|
|
226
272
|
}
|
|
273
|
+
// Approve or Skip on a draft's preview: your only step in a post. Approved, its agent is
|
|
274
|
+
// told in ANSWERS.md to post it (or schedule it for its date) through Symbiot's signed-in
|
|
275
|
+
// browser with exactly the text you saw, and starts; skipped, it's told not to.
|
|
276
|
+
function marketingDraftAnswer(rel, status, deps = {}) {
|
|
277
|
+
const dir = deps.dir || MARKETING_DIR, r = (deps.set || setDraftStatus)(String(rel || ""), status, { dir });
|
|
278
|
+
if (r.error) return r;
|
|
279
|
+
cached = null;
|
|
280
|
+
const d = r.draft, when = d.when ? `, scheduled in ${d.platform}'s own scheduler for ${d.when}` : "";
|
|
281
|
+
if (status === "skipped") { noteAnswer(dir, `Skipped: ${rel}`, "Don't post it. Leave the draft where it is."); return { ok: true, said: "Skipped. Its agent won't post it." }; }
|
|
282
|
+
noteAnswer(dir, `Approved: ${rel}`, `The user approved its preview. Post it on ${d.platform}${when}, through Symbiot's signed-in browser, with exactly the text under its post (${d.body.length} characters, unchanged)${d.media.length ? ` and ${d.media.join(", ")} attached` : ""}. Then check it's there, and say so in your last message.`);
|
|
283
|
+
if ((deps.running || runningHandoff)(dir)) return { ok: true, said: "Approved. Its agent posts it once the run there now finishes." };
|
|
284
|
+
const e = (deps.run || runHandoff)(dir, { force: true });
|
|
285
|
+
return e && e.id && !e.blocked ? { ok: true, said: `Approved. Its agent is ${d.when ? "scheduling" : "posting"} it now.`, rerun: e.id } : { ok: true, said: `Approved. ${(e && e.note) || "Start its agent to post it."}` };
|
|
286
|
+
}
|
|
227
287
|
// Start Marketing's agent on its waiting tasks (the page's Start its agent). { ok } or { error }.
|
|
228
288
|
function marketingGo(deps = {}) {
|
|
229
289
|
cached = null;
|
|
@@ -244,6 +304,8 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
244
304
|
const reports = deps.reports || (() => { try { return reportsNews(); } catch { return { count: 0 }; } });
|
|
245
305
|
const connected = deps.connected || (() => !!resolveProvider());
|
|
246
306
|
const repos = deps.repos || (() => { try { return laneMap(); } catch { return {}; } });
|
|
307
|
+
const search = deps.search || (() => { try { return reposState(); } catch { return { searching: false, at: 0, list: [] }; } });
|
|
308
|
+
const confirmed = deps.confirmed || (() => { try { return !!loadConfig().reposConfirmed; } catch { return false; } });
|
|
247
309
|
const waits = deps.waits || (() => { try { return awaitingState().waits; } catch { return []; } });
|
|
248
310
|
const clashes = deps.clashes || (() => { try { return newClashes(); } catch { return []; } });
|
|
249
311
|
// can a reply be noticed? Email on (mail on this computer), or an inbox watched and signed in
|
|
@@ -265,12 +327,16 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
265
327
|
if (!connected()) you.push({ kind: "setup", id: "setup:ai", urgent: true, title: "Connect an AI", sub: "Symbiot can't work without one: your Claude subscription (sign in to Claude Code), a key, or a free local model", shape: "settings", focus: "ai" });
|
|
266
328
|
const gone = agentGone(); if (gone) you.push({ kind: "setup", id: "setup:agent", urgent: true, title: "Your agent is unavailable", sub: `${gone} isn't on this computer any more, so no task can start`, shape: "settings", focus: "agent" });
|
|
267
329
|
for (const c of signedOut()) you.push({ kind: "setup", id: "setup:conn:" + c.id, urgent: true, title: `Reconnect ${c.name}`, sub: `its Claude connector is signed out, so agent runs can't use ${c.name}`, shape: "settings", focus: "agent" });
|
|
268
|
-
|
|
269
|
-
// nothing in them (or is cut short) is Symbiot's to retry, never a card asking again.
|
|
270
|
-
if (!rootsSet() && !Object.keys(map).some((n) => map[n] !== MARKETING_DIR)) you.push({ kind: "setup", id: "setup:folders", title: "Show me your work", sub: "where your repos are", shape: "settings", focus: "work" });
|
|
330
|
+
you.push(...reposCard({ map, search: search(), confirmed: confirmed(), rootsSet: rootsSet() }));
|
|
271
331
|
// no agent yet: Send to repos and Go would only write TASKS.md files nothing runs, so
|
|
272
|
-
// Home didn't say "All handled" truthfully.
|
|
273
|
-
|
|
332
|
+
// Home didn't say "All handled" truthfully. Which agent is on this computer is a
|
|
333
|
+
// lookup, not your call: one found is set (change it in Settings), and you're asked
|
|
334
|
+
// only when there's none to set, or you cleared yours.
|
|
335
|
+
if (!agentCmd()) {
|
|
336
|
+
const p = offer(), off = deps.agentOff ? deps.agentOff() : (() => { try { return !!loadConfig().agentOff; } catch { return false; } })();
|
|
337
|
+
if (p && !off) (deps.setAgent || setHandoffCmd)(p.tmpl);
|
|
338
|
+
else you.push({ kind: "setup", id: "setup:pick", title: "Pick your agent", sub: p ? `${p.name} is on this computer: one click and it takes your tasks` : "no coding agent on this computer yet: install one (Claude Code, Codex, Gemini or Aider) and it takes your tasks", shape: "settings", focus: "agent", ...(p ? { pick: { name: p.name, tmpl: p.tmpl } } : {}) });
|
|
339
|
+
}
|
|
274
340
|
const pend = pending();
|
|
275
341
|
for (const r of pend) {
|
|
276
342
|
if (!r.path || r.running) continue;
|
|
@@ -290,7 +356,8 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
290
356
|
// What stopped and can't go on without you, as a question too: a handover that
|
|
291
357
|
// couldn't start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"),
|
|
292
358
|
// and a run that ended in an error. Answered on its blob (homeAnswer).
|
|
293
|
-
|
|
359
|
+
const drafts = deps.drafts || (() => { try { return draftCards(); } catch { return []; } });
|
|
360
|
+
you.push(...troubles({ stuck: stuck(), list, map, named, now, drafts: drafts() }));
|
|
294
361
|
// A run is waiting on an emailed reply that nothing will see: say so, once,
|
|
295
362
|
// until an inbox is watched (and signed in) or Email is on (handback.mjs looks in either).
|
|
296
363
|
if (waits().some((w) => w.status === "waiting")) {
|
|
@@ -350,6 +417,9 @@ function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
|
|
|
350
417
|
const e = (deps.run || runHandoff)(a.path, { force: true }); dropped.add(ref);
|
|
351
418
|
return e && e.id && !e.blocked ? { ok: true, said: "Running it again.", rerun: e.id } : { error: (e && e.note) || "It didn't start. Check your agent in Settings." };
|
|
352
419
|
}
|
|
420
|
+
if (id.startsWith("repos:")) return reposAnswer({ pick, text }, deps);
|
|
421
|
+
// a drafted reply: Go ahead sends it, Skip drops it, your own words are Change it (a redraft on the same card)
|
|
422
|
+
if (id.startsWith("draft:")) return (deps.draftAnswer || draftAnswer)(ref, text ? (SAYS_NO.test(text) ? { skip: true } : { change: text }) : go ? { go: true } : { skip: true });
|
|
353
423
|
if (id.startsWith("needs:")) {
|
|
354
424
|
const a = (deps.agents || agentsList)().find((x) => x.id === ref), n = a && a.needs;
|
|
355
425
|
if (!n) return { error: "That isn't waiting on you any more." };
|
|
@@ -428,6 +498,50 @@ async function homeAsk(question, { ask, now = Date.now(), state, images = [] } =
|
|
|
428
498
|
return { answer: r.reply, ...(r.did ? { did: r.did } : {}), steps: r.steps || [] };
|
|
429
499
|
}
|
|
430
500
|
|
|
501
|
+
// ---- acting on a report, where you read it (reports.mjs reportIdeas) -------------------
|
|
502
|
+
// A report ends with ideas drawn from it, and a box to ask about it: you shouldn't have
|
|
503
|
+
// to copy its "Top 3 next" into Home's chat to act on it (2026-10-08).
|
|
504
|
+
const reportById = (id, deps = {}) => (deps.list || listReports)().find((r) => r.id === String(id || ""));
|
|
505
|
+
// The ideas you ticked, onto the Workdesk in the report's lane (an ops run's: the ops
|
|
506
|
+
// lane), each saying which report it came from. { ok, added, lane, said } or { error }.
|
|
507
|
+
function reportIdeasAdd(id, ideas, deps = {}) {
|
|
508
|
+
const r = reportById(id, deps); if (!r) return { error: "That report isn't there any more." };
|
|
509
|
+
const list = (Array.isArray(ideas) ? ideas : []).map((x) => String(x || "").trim()).filter(Boolean).slice(0, 8);
|
|
510
|
+
if (!list.length) return { error: "Tick an idea first." };
|
|
511
|
+
const lane = r.lane || RUNS_LANE, add = deps.add || addTask;
|
|
512
|
+
const added = list.map((t) => add(t, lane, { after: `(from the report "${r.title}", ${r.file})` })).filter((t) => t && !t.error);
|
|
513
|
+
cached = null;
|
|
514
|
+
return added.length ? { ok: true, added: added.length, lane, said: `${added.length === 1 ? "It's" : `${added.length} are`} on the Workdesk, in ${lane === RUNS_LANE ? "ops" : lane}.` } : { error: "Couldn't add them." };
|
|
515
|
+
}
|
|
516
|
+
// Ask about the report in place: the same mind as Home's chat, the report in front of it.
|
|
517
|
+
async function reportAsk(id, question, { ask, deps = {} } = {}) {
|
|
518
|
+
question = String(question || "").trim().slice(0, 2000); if (!question) return { error: "empty" };
|
|
519
|
+
const r = (deps.read || readReport)(id, { mark: false }); if (!r || r.error) return { error: (r && r.error) || "That report isn't there any more." };
|
|
520
|
+
if (!ask && !resolveProvider()) return { error: "not-connected" };
|
|
521
|
+
let map = {}; try { map = laneMap(); } catch {}
|
|
522
|
+
const lane = r.lane && r.lane !== RUNS_LANE && map[r.lane] ? r.lane : "";
|
|
523
|
+
const role = `Here they're reading a report an agent wrote, "${r.title}"${lane ? ` (${lane})` : ""}, under Reports. Answer about it from its text; a task or agent work it leads to goes to ${lane || "the lane it belongs to"} unless they say otherwise.`;
|
|
524
|
+
const res = await converse({ where: `Reports: ${r.title}`, role, context: `The report (${r.file}):\n${String(r.text || "").slice(0, 12000)}`, question, map, ...(ask ? { ask } : {}),
|
|
525
|
+
act: {
|
|
526
|
+
agent: (req, known, repo) => ((repo || lane) ? actIn(req, repo || lane, { map, known, title: "Reports" }) : actNow(req, { title: "Reports", known })),
|
|
527
|
+
task: (text, repo) => taskIn(text, repo || lane, { map }),
|
|
528
|
+
} });
|
|
529
|
+
return { answer: res.reply, ...(res.did ? { did: res.did } : {}), steps: res.steps || [] };
|
|
530
|
+
}
|
|
531
|
+
// Approve or Reject on a draft report: its agent is told in ANSWERS.md, and an approved
|
|
532
|
+
// one goes ahead (its agent starts, or takes it once the run there finishes).
|
|
533
|
+
function reportDraftAnswer(id, approve, deps = {}) {
|
|
534
|
+
const r = reportById(id, deps); if (!r) return { error: "That report isn't there any more." };
|
|
535
|
+
const rel = `.symbiot/${r.name}`;
|
|
536
|
+
(deps.decide || decide)(r, approve ? "approved" : "rejected");
|
|
537
|
+
cached = null;
|
|
538
|
+
if (!approve) { noteAnswer(r.folder, `Rejected: ${rel}`, `The user rejected the draft "${r.title}". Don't use or send it. If they said why, it's below.`); return { ok: true, said: "Rejected. Its agent won't use it." }; }
|
|
539
|
+
noteAnswer(r.folder, `Approved: ${rel}`, `The user approved the draft "${r.title}" as it reads in ${rel}. Go ahead with it, unchanged, and say what you did in your last message.`);
|
|
540
|
+
if ((deps.running || runningHandoff)(r.folder)) return { ok: true, said: "Approved. Its agent goes ahead once the run there now finishes." };
|
|
541
|
+
const e = (deps.run || runHandoff)(r.folder, { force: true });
|
|
542
|
+
return e && e.id && !e.blocked ? { ok: true, said: "Approved. Its agent is going ahead with it.", rerun: e.id } : { ok: true, said: `Approved. ${(e && e.note) || "Start its agent to go ahead."}` };
|
|
543
|
+
}
|
|
544
|
+
|
|
431
545
|
// ---- the work scene: the liquid when you open Tasks or Agents ---------------------
|
|
432
546
|
// Plain words for someone who doesn't read diffs: what each agent is doing now,
|
|
433
547
|
// what's waiting its turn, what's done and waiting for your OK, and whether
|
|
@@ -448,7 +562,7 @@ function runNow(a, { now = Date.now(), est = estimate } = {}) {
|
|
|
448
562
|
// first (a question, an Approve), then what's at work, ready, waiting, and the
|
|
449
563
|
// most recent. Ops runs (act-…: no repo of their own) are one "Agent runs" lane,
|
|
450
564
|
// not a sphere each with an id for a name.
|
|
451
|
-
function workScene({ deps = {} } = {}) {
|
|
565
|
+
function workScene({ deps = {}, full = false } = {}) {
|
|
452
566
|
const agents = deps.agents || (() => { try { return agentsList(); } catch { return []; } });
|
|
453
567
|
const pending = deps.pending || (() => { try { return pendingReview(); } catch { return []; } });
|
|
454
568
|
const tasks = deps.tasks || (() => { try { return loadTasks(); } catch { return []; } });
|
|
@@ -469,7 +583,10 @@ function workScene({ deps = {} } = {}) {
|
|
|
469
583
|
}
|
|
470
584
|
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 }));
|
|
471
585
|
const busy = new Set(running.map((r) => r.lane)), open = tasks().filter((t) => !t.done && !t.archived && !t.review && t.repo);
|
|
472
|
-
|
|
586
|
+
// why each one waits, in plain words: on it now, queued behind a run, waiting on you, blocked, or next
|
|
587
|
+
const held = {}; for (const a of AG) { if (a.status === "running") continue; const l = laneOf(a), nq = (a.ask && a.ask.questions && a.ask.questions.length) || 0; if (!held[l] && (nq || a.waiting || (a.needs && !a.needs.waiting))) held[l] = { path: a.path, approve: !nq && !a.waiting && a.needs && a.needs.kind === "approve" }; }
|
|
588
|
+
const why = waitWhy({ map, named, running, held, ready: new Set(ready.map((r) => r.repo)), isParked, deps });
|
|
589
|
+
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 } : {}), ts: Number(t.ts) || 0, why: why(t) })); // the app shortens it for a tag
|
|
473
590
|
// Projects: each lane with work on it, what's going on there in one line's worth
|
|
474
591
|
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 } : {}) });
|
|
475
592
|
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); });
|
|
@@ -495,25 +612,109 @@ function workScene({ deps = {} } = {}) {
|
|
|
495
612
|
for (const s of ideas) { p.ideas = p.ideas || []; if (p.ideas.length < 4) p.ideas.push({ text: plain(s.text, 200), full: String(s.text), repo: s.repo || l, path: a.path }); }
|
|
496
613
|
}
|
|
497
614
|
const stuck = deps.stuck || (() => { try { return stuckHandovers(); } catch { return []; } });
|
|
498
|
-
|
|
615
|
+
const drafts = deps.drafts || (() => { try { return draftCards(); } catch { return []; } });
|
|
616
|
+
for (const t of troubles({ stuck: stuck(), list: AG, map, named, drafts: drafts() })) {
|
|
499
617
|
const p = at(t.repo || RUNS_LANE); p.asks++; p.qs = p.qs || [];
|
|
500
|
-
if (p.qs.length < 3) p.qs.unshift({ q: t.q, options: t.options, path: t.path, fix: t.fix, id: t.id, sub: t.sub });
|
|
618
|
+
if (p.qs.length < 3) p.qs.unshift({ q: t.q, options: t.options, path: t.path, fix: t.fix, id: t.id, sub: t.sub, ...(t.draft ? { draft: t.draft } : {}) });
|
|
501
619
|
}
|
|
502
620
|
for (const p of Object.values(by)) { p.lit = !!(p.asks || p.ready); if (isParked(p.repo)) p.parked = true; }
|
|
503
621
|
const projects = Object.values(by).sort((a, b) => (b.lit ? 1 : 0) - (a.lit ? 1 : 0) || (a.parked ? 1 : 0) - (b.parked ? 1 : 0) || (b.running ? 1 : 0) - (a.running ? 1 : 0) || b.ready - a.ready || b.waiting - a.waiting || b.last - a.last || (a.repo < b.repo ? -1 : 1));
|
|
504
|
-
|
|
622
|
+
const stuckN = waiting.filter((w) => w.why.stuck).length;
|
|
623
|
+
return { running, ready, waiting: waiting.slice(0, 12), ...(full ? { all: waiting } : {}), waitingCount: waiting.length, canGo: waiting.filter((w) => !w.busy && !w.parked && !w.why.noGo).length, ...(stuckN ? { stuckCount: stuckN } : {}), projects: projects.slice(0, 24), projectCount: projects.length };
|
|
624
|
+
}
|
|
625
|
+
// ---- why a task waits ---------------------------------------------------------------
|
|
626
|
+
// Home showed "7 waiting" and nothing said why or moved them (2026-10-08): an ops task
|
|
627
|
+
// ("🤖 Agent: free space on /mnt/storage") had no folder, so Go passed it over, and a
|
|
628
|
+
// repo's tasks sat until someone pressed Go. Each waiting task now says why, in a
|
|
629
|
+
// line: its lane's agent is on it now; it's queued behind the run in its lane (named);
|
|
630
|
+
// it waits on your answer or your OK (its Workdesk card: `path`); it's blocked (no
|
|
631
|
+
// folder, no agent, parked, the start failed); or it's next, and starts by itself
|
|
632
|
+
// (workTick). One whose lane is free and has waited past STUCK_AFTER, or whose run
|
|
633
|
+
// ended without ticking it, is flagged (`stuck`) rather than started again and again.
|
|
634
|
+
const AUTO_GRACE = 30000, STUCK_AFTER = 5 * 60000, RETRY_AFTER = 5 * 60000, AUTO_FAIL = {}; // lane → { at, note }: a start that didn't take
|
|
635
|
+
const yours = (text) => /^\s*👤/.test(String(text || "")); // "👤 You (only you: …)": the user's step, never an agent's
|
|
636
|
+
const asAgent = (text) => String(text || "").replace(/^\s*🤖\s*/u, "").replace(/^Agent:\s*/i, "").trim();
|
|
637
|
+
const minutes = (ms) => { const m = Math.round(ms / 60000); return m >= 120 ? `${Math.round(m / 60)} hours` : `${m} min`; };
|
|
638
|
+
// What a folder's agent was last handed (agents.mjs handed.json) and when its last run started.
|
|
639
|
+
function briefOf(path) { try { const h = JSON.parse(readFileSync(join(path, ".symbiot", "handed.json"), "utf8")); return { at: Number(h.at) || 0, tasks: Array.isArray(h.tasks) ? h.tasks : [] }; } catch { return { at: 0, tasks: [] }; } }
|
|
640
|
+
function lastRunAt(path) { const r = loadRuns().find((x) => x.path === path); return (r && Number(r.startedAt)) || 0; }
|
|
641
|
+
function waitWhy({ map, named, running, held, ready, isParked, deps = {} }) {
|
|
642
|
+
const brief = deps.brief || briefOf, lastRun = deps.lastRun || lastRunAt, now = deps.now || Date.now();
|
|
643
|
+
const agentSet = !!(deps.cmd || handoffCmd)(), fails = deps.fails || AUTO_FAIL;
|
|
644
|
+
return (t) => {
|
|
645
|
+
const lane = t.repo, name = lane === RUNS_LANE ? "ops" : named(map[lane], lane), age = now - (Number(t.ts) || now);
|
|
646
|
+
const flag = (text) => ({ kind: "stuck", stuck: true, text });
|
|
647
|
+
if (yours(t.text)) return { kind: "you", noGo: true, text: "Yours to do: no agent can, so nothing starts it" };
|
|
648
|
+
if (isParked(lane)) return { kind: "parked", text: `Blocked: ${name} is parked, so nothing starts there until you unpark it` };
|
|
649
|
+
if (!agentSet) return { kind: "blocked", text: "Blocked: no coding agent is set (Settings → Handoff)" };
|
|
650
|
+
const fail = fails[lane];
|
|
651
|
+
if (lane === RUNS_LANE) { // each gets an agent run of its own: never queued behind another
|
|
652
|
+
if (fail) return { kind: "blocked", failAt: fail.at, text: `Blocked: it didn't start (${fail.note})` };
|
|
653
|
+
return age > STUCK_AFTER ? flag(`Not started after ${minutes(age)}, with nothing in its way`) : { kind: "next", text: "Next: starts as an agent run of its own" };
|
|
654
|
+
}
|
|
655
|
+
const path = map[lane]; if (!path) return { kind: "blocked", noGo: true, text: `Blocked: Symbiot can't find ${name}'s folder` };
|
|
656
|
+
const b = brief(path), inBrief = b.tasks.some((x) => sameTask(x, t.text)), run = running.find((r) => r.lane === lane);
|
|
657
|
+
if (run) return inBrief ? { kind: "working", text: `${name}'s agent is on it now` } : { kind: "queued", text: `Queued behind ${name}'s run: ${plain(run.doing, 60).toLowerCase()}`, run: run.doing };
|
|
658
|
+
const h = held[lane];
|
|
659
|
+
if (h) return h.approve ? { kind: "approve", noGo: true, text: `Waiting on your OK for ${name}'s last run, on the Workdesk`, path: h.path } : { kind: "asks", noGo: true, text: `Waiting on your answer to ${name}'s question, on the Workdesk`, path: h.path };
|
|
660
|
+
if (inBrief && ready.has(lane)) return { kind: "approve", noGo: true, text: `Waiting on your OK for ${name}'s work, on the Workdesk`, path };
|
|
661
|
+
if (fail) return { kind: "blocked", failAt: fail.at, text: `Blocked: ${name}'s agent didn't start (${fail.note})` };
|
|
662
|
+
if (inBrief && lastRun(path) >= b.at) return { ...flag(`${name}'s last run ended without finishing it: Go starts it again`), ended: true };
|
|
663
|
+
return age > STUCK_AFTER ? flag(`Not started after ${minutes(age)}, with nothing in its way`) : { kind: "next", text: `Next: starts on its own, ${name}'s lane is free` };
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
// Start what's next by itself, every 20 seconds (server.mjs): a lane's waiting tasks go
|
|
667
|
+
// to its agent once it's free (lanes in parallel, one run per lane), and an ops task
|
|
668
|
+
// ("🤖 Agent: …") gets an agent run of its own, each in parallel, and leaves the list
|
|
669
|
+
// (its run is on the Workdesk). A task gets AUTO_GRACE first, so a few added together
|
|
670
|
+
// go out in one brief. A start that didn't take is kept and shown as the block; the
|
|
671
|
+
// next tick tries again. config.autoStart: false leaves it all to Go.
|
|
672
|
+
// { started: [lanes], ops: [task ids], failed: [lanes] }
|
|
673
|
+
function workTick({ deps = {} } = {}) {
|
|
674
|
+
const out = { started: [], ops: [], failed: [] };
|
|
675
|
+
if ((deps.config || loadConfig)().autoStart === false || !(deps.cmd || handoffCmd)()) return out;
|
|
676
|
+
const now = deps.now || Date.now(), fails = deps.fails || AUTO_FAIL, push = deps.push || pushTasks, run = deps.run || runHandoff;
|
|
677
|
+
const w = workScene({ deps: { ...deps, fails, now }, full: true }), lanes = new Set(), ops = [];
|
|
678
|
+
for (const x of w.all) {
|
|
679
|
+
if (x.why.kind !== "next" && !(x.why.stuck && !x.why.ended) && !(x.why.failAt && now - x.why.failAt >= RETRY_AFTER)) continue; // a failed start: tried again every few minutes
|
|
680
|
+
if (now - (x.ts || 0) < AUTO_GRACE) continue;
|
|
681
|
+
if (x.repo === RUNS_LANE) ops.push(x.id.slice(5)); else lanes.add(x.repo);
|
|
682
|
+
}
|
|
683
|
+
Object.assign(out, startOps(ops, { ...deps, fails, now }));
|
|
684
|
+
for (const lane of lanes) {
|
|
685
|
+
const p = push({ repo: lane }), wr = p && p.written && p.written[0]; if (!wr) continue;
|
|
686
|
+
const e = run(wr.path);
|
|
687
|
+
if (e && e.id && !e.busy && !e.blocked) { delete fails[lane]; out.started.push(lane); }
|
|
688
|
+
else if (!e || (e.blocked && !e.parked)) { fails[lane] = { at: now, note: plain((e && e.note) || "the agent command didn't run", 120) }; out.failed.push(lane); }
|
|
689
|
+
}
|
|
690
|
+
return out;
|
|
691
|
+
}
|
|
692
|
+
// Ops tasks (by id) each to an agent run of its own; one that started leaves the list
|
|
693
|
+
// (done, its run's folder in `act`). { ops: [ids started], failed: ["ops"]? }
|
|
694
|
+
function startOps(ids, { tasks = loadTasks, save = saveTasks, act = actNow, fails = AUTO_FAIL, now = Date.now() } = {}) {
|
|
695
|
+
const out = { ops: [], failed: [] };
|
|
696
|
+
for (const id of ids) {
|
|
697
|
+
const all = tasks(), t = all.find((k) => k.id === id); if (!t) continue;
|
|
698
|
+
const r = act(asAgent(t.full || t.text), { title: "from your tasks" });
|
|
699
|
+
if (r && r.ok) { delete fails[RUNS_LANE]; Object.assign(t, { done: true, doneAt: now, act: r.dir }); save(all); out.ops.push(id); }
|
|
700
|
+
else { fails[RUNS_LANE] = { at: now, note: plain((r && r.error) || "the agent didn't start", 120) }; out.failed = [RUNS_LANE]; break; }
|
|
701
|
+
}
|
|
702
|
+
return out;
|
|
505
703
|
}
|
|
506
704
|
// Go: everything waiting goes to its repo's agent, the way Send to repos and an
|
|
507
705
|
// agent per repo would: briefs written, an agent started in each (or queued
|
|
508
706
|
// behind one already there); a parked project's wait for it to be unparked.
|
|
509
707
|
// { started, queued, repos, parked? } or { error }.
|
|
510
|
-
function workGo({ push = pushTasks, run = runHandoff } = {}) {
|
|
511
|
-
const r = push({});
|
|
512
|
-
if (r.empty) return { started: 0, queued: 0, repos: [], note: "Nothing waiting to start." };
|
|
513
|
-
let started =
|
|
708
|
+
function workGo({ push = pushTasks, run = runHandoff, ops = goOps } = {}) {
|
|
709
|
+
const o = ops(), r = push({});
|
|
710
|
+
if (r.empty && !o.ops.length && !o.failed.length) return { started: 0, queued: 0, repos: [], note: "Nothing waiting to start." };
|
|
711
|
+
let started = o.ops.length, queued = 0; const repos = o.ops.length ? ["ops"] : [], parked = [];
|
|
514
712
|
for (const w of r.written || []) { const e = run(w.path); if (e && e.parked) { parked.push(w.name); continue; } if (e && e.id && !e.busy && !e.blocked) started++; else queued++; repos.push(w.name); }
|
|
515
|
-
|
|
713
|
+
const unresolved = (r.unresolved || []).map((u) => u.name).filter((n) => n !== RUNS_LANE);
|
|
714
|
+
return { started, queued, repos, ...(parked.length ? { parked } : {}), ...(unresolved.length ? { unresolved } : {}), ...(o.failed.length ? { note: `The ops tasks didn't start: ${AUTO_FAIL[RUNS_LANE].note}` } : {}) };
|
|
516
715
|
}
|
|
716
|
+
// Go's ops tasks: every one an agent can do, each to a run of its own.
|
|
717
|
+
const goOps = () => startOps(loadTasks().filter((t) => t.repo === RUNS_LANE && !t.done && !t.archived && !t.review && !yours(t.text)).map((t) => t.id));
|
|
517
718
|
|
|
518
719
|
// ---- setup (the first run) --------------------------------------------------------
|
|
519
720
|
// A new Symbiot walks you through setup before anything else: what it is, your AI,
|
|
@@ -534,7 +735,7 @@ function onboarding({ fresh = false } = {}) {
|
|
|
534
735
|
return {
|
|
535
736
|
pending: !!o.pending, step: ONB_STEPS.includes(o.step) ? o.step : "welcome", steps: ONB_STEPS,
|
|
536
737
|
ai: { connected: !!r, provider: r ? r.provider : "", line: r ? `${PROVIDERS[r.provider].label}${r.model ? " · " + r.model : ""}` : "", claude: st ? { installed: st.installed, signedIn: st.signedIn } : null },
|
|
537
|
-
work: { searching: reps.searching, done: reps.done || 0, total: reps.total || 0, count: reps.list.length, repos: reps.list.slice(0, 60).map((x) => ({ name: x.name, path: x.path })), roots: tryOr(() => scanRoots(), []).map((r) => (r === homedir() ? "~" : r.startsWith(homedir() + "/") ? "~" + r.slice(homedir().length) : r)) },
|
|
738
|
+
work: { searching: reps.searching, at: reps.at || 0, done: reps.done || 0, total: reps.total || 0, count: reps.list.length, repos: reps.list.slice(0, 60).map((x) => ({ name: x.name, path: x.path })), roots: tryOr(() => scanRoots(), []).map((r) => (r === homedir() ? "~" : r.startsWith(homedir() + "/") ? "~" + r.slice(homedir().length) : r)) },
|
|
538
739
|
agent: { cmd, pick: tryOr(() => pickAgent(), null), agents: (det.agents || []).map((a) => ({ label: a.label, tmpl: a.tmpl })) },
|
|
539
740
|
apps, groups: links.groups || [], decided: apps.every((a) => a.skipped || a.state !== "off"),
|
|
540
741
|
docs: { folders: tryOr(() => (knowledgeState().folders || []).map((f) => f.path), []) },
|
|
@@ -553,4 +754,4 @@ function setOnboarding({ step, skip, unskip, skipRest, done, restart } = {}) {
|
|
|
553
754
|
return onboarding();
|
|
554
755
|
}
|
|
555
756
|
|
|
556
|
-
export { marketingState, marketingGo, marketingTask, moveToMarketing, goLane, homeState, homeContext, homeAsk, homeAnswer, homeNext, nextUp, laneNamed, NEXT_FILE, workScene, workGo, displayName, firstSteps, onboarding, setOnboarding, ONB_STEPS };
|
|
757
|
+
export { reportIdeasAdd, reportAsk, reportDraftAnswer, workTick, waitWhy, AUTO_GRACE, STUCK_AFTER, marketingState, marketingGo, marketingDraftAnswer, marketingTask, moveToMarketing, goLane, homeState, homeContext, homeAsk, homeAnswer, homeNext, nextUp, laneNamed, NEXT_FILE, workScene, workGo, displayName, firstSteps, onboarding, setOnboarding, ONB_STEPS };
|
package/index.mjs
CHANGED
|
@@ -41,7 +41,7 @@ import { loadScreens, screenImage, blueprint } from "./screens.mjs";
|
|
|
41
41
|
import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, isTrusted } from "./headless.mjs";
|
|
42
42
|
import { watchState, addWatch, removeWatch, seenWatch, newsSince, markNews, checkWatch, setBrief, draftReply, watchBoard, boardLine, boardChat, boardTalk, clearBoardChat } from "./watch.mjs";
|
|
43
43
|
import { PORT as PHONE_PORT, phoneState, pairComputer, pollComputer, forgetComputer } from "./phone.mjs";
|
|
44
|
-
import { KIND_LABEL as POST_KIND, PATHS as POST_PATHS, draftPosts, postsState, postLog, approvePost, editPost, skipPost, voiceFromLinkedIn,
|
|
44
|
+
import { KIND_LABEL as POST_KIND, PATHS as POST_PATHS, draftPosts, postsState, postLog, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia as addPostMedia, removeMedia as removePostMedia, mediaDir as postMediaDir, pictureOfPage, clipOfPage } from "./post.mjs";
|
|
45
45
|
import { startApp, updateCmd, isAppRunningWeekly, askRunningApp, openApp } from "./server.mjs";
|
|
46
46
|
import { listReports, readReport } from "./reports.mjs";
|
|
47
47
|
import { runSandbox } from "./sandbox.mjs";
|
|
@@ -678,8 +678,8 @@ async function cmdPhone() {
|
|
|
678
678
|
}
|
|
679
679
|
|
|
680
680
|
// ---- `symbiot post`: the week's real work as 3 draft posts you approve ----------
|
|
681
|
-
// Nothing is
|
|
682
|
-
//
|
|
681
|
+
// Nothing is posted until you approve one: then Marketing's agent posts it, through
|
|
682
|
+
// Symbiot's browser signed in to LinkedIn.
|
|
683
683
|
async function cmdPost() {
|
|
684
684
|
const [sub = "draft", a1, a2] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--since" && all[i - 1] !== "--seconds");
|
|
685
685
|
const fail = (msg) => { console.log(c.y(msg)); process.exitCode = 1; };
|
|
@@ -710,11 +710,8 @@ async function cmdPost() {
|
|
|
710
710
|
const r = approvePost(a1);
|
|
711
711
|
if (r.error) return fail(r.error);
|
|
712
712
|
console.log(`${c.g("✓")} Approved.\n\n${r.post.text}\n`);
|
|
713
|
-
console.log(r.
|
|
714
|
-
|
|
715
|
-
if (r.files.length) console.log(`Then add ${r.files.length === 1 ? "this" : "these"} with its photo or video button:\n${r.files.map((f) => " " + f).join("\n")}` + (has("open") ? "" : c.d(" (--open opens their folder)")));
|
|
716
|
-
if (has("open")) { openUrl(r.share); if (r.folder) openUrl(r.folder); }
|
|
717
|
-
console.log(c.d(r.note));
|
|
713
|
+
console.log(r.handed.error ? c.y(r.note) : r.note);
|
|
714
|
+
if (r.handed.error) process.exitCode = 1;
|
|
718
715
|
return;
|
|
719
716
|
}
|
|
720
717
|
// pictures and video on a draft: a file of yours, or a picture or clip of a page
|
|
@@ -768,9 +765,8 @@ async function cmdPost() {
|
|
|
768
765
|
fixed, a longer one) from your last 7 days of
|
|
769
766
|
commits, release tags and CHANGELOG.md
|
|
770
767
|
symbiot post list the drafts waiting on you
|
|
771
|
-
symbiot post approve <id>
|
|
772
|
-
|
|
773
|
-
open) LinkedIn's share box to paste it into
|
|
768
|
+
symbiot post approve <id> approve it: Marketing's agent posts it on
|
|
769
|
+
LinkedIn through Symbiot's signed-in browser
|
|
774
770
|
symbiot post edit <id> "text" change a draft's words
|
|
775
771
|
symbiot post add <id> <file> put a picture or video of yours on a draft
|
|
776
772
|
(PNG, JPG, GIF, MP4, MOV, WebM)
|
package/lanes.mjs
CHANGED
|
@@ -21,9 +21,9 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, chmodS
|
|
|
21
21
|
import { createHash, randomBytes } from "node:crypto";
|
|
22
22
|
import { execFile } from "node:child_process";
|
|
23
23
|
import { CONFIG_DIR, loadTasks, clipWords } from "./core.mjs";
|
|
24
|
-
import { runHandoff, runningHandoff, waitingFor, agentQuestions, findOrcaCli, installAllowlist } from "./agents.mjs";
|
|
25
|
-
import { addTask, pushTasks } from "./tasks.mjs";
|
|
26
|
-
import { actNow } from "./mind.mjs";
|
|
24
|
+
import { runHandoff, runningHandoff, waitingFor, agentQuestions, findOrcaCli, installAllowlist, writeTasks } from "./agents.mjs";
|
|
25
|
+
import { addTask, removeTask, pushTasks } from "./tasks.mjs";
|
|
26
|
+
import { actNow, actBrief } from "./mind.mjs";
|
|
27
27
|
import { laneMap } from "./scan.mjs";
|
|
28
28
|
import { parseRun, lastRunText, readRunLog } from "./work.mjs";
|
|
29
29
|
import { OPS, parseHandoffs } from "./handover.mjs";
|
|
@@ -51,15 +51,56 @@ function laneTarget(lane, map) {
|
|
|
51
51
|
return name ? { lane: name, path: map[name] } : null;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// A handover whose text changed while the run doing the earlier version is still
|
|
55
|
+
// going (the LinkedIn auto-poster spec reached ops twice, on top of a run already
|
|
56
|
+
// building it) supersedes that run rather than starting a second one: the same
|
|
57
|
+
// ledger row takes the new text, and the lane's agent gets it once it's free.
|
|
58
|
+
// Ops: the new brief waits in that run's folder (writeTasks holds it while it
|
|
59
|
+
// runs; it lands and starts there, resuming the same conversation, when it exits),
|
|
60
|
+
// with a note in its ANSWERS.md saying what changed. A repo: the earlier task
|
|
61
|
+
// gives way to the new one. The earlier row: same asker, same lane, not reported
|
|
62
|
+
// back, about the same thing (alike), and its run still going (ops) or its task
|
|
63
|
+
// still open (a repo). null when there's none.
|
|
64
|
+
function supersedes(h, from, ledger, { running, tasks }) {
|
|
65
|
+
return ledger.handoffs.slice().reverse().find((x) => x.from.path === from.path && x.to.lane.toLowerCase() === h.lane.toLowerCase() && !x.reportedAt && (x.status === "started" || x.status === "held") && x.text !== h.text && alike(x.text, h.text)
|
|
66
|
+
&& (x.to.lane === OPS ? !!x.to.path && !!running(x.to.path) : !!x.task && tasks().some((t) => t.id === x.task && !t.done && !t.archived && !t.review))) || null;
|
|
67
|
+
}
|
|
68
|
+
function resupply(e, text, { now, title, context }) {
|
|
69
|
+
const p = e.to.path;
|
|
70
|
+
try {
|
|
71
|
+
writeTasks(p, actBrief(text, { title, context, now }));
|
|
72
|
+
const file = join(p, ".symbiot", "ANSWERS.md"), had = existsSync(file) ? readFileSync(file, "utf8") : "# Answers\n";
|
|
73
|
+
writeFileSync(file, `${had.replace(/\s*$/, "")}\n\n### The handover changed while you ran\n${e.from.lane} rewrote what it handed over: TASKS.md has the new version, and it replaces the one you started on. Carry on from what you've done, to the new version.\n_symbiot (handover) ${new Date(now).toISOString().slice(0, 10)}_\n`);
|
|
74
|
+
return { ok: true };
|
|
75
|
+
} catch (err) { return { error: "Couldn't hand the new version to the run already going: " + ((err && err.message) || err) }; }
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
// Pick up what a folder's agent handed over and start the lanes it named.
|
|
55
79
|
// Each handover is started once (its key: where from, which lane, what).
|
|
56
|
-
function dispatch(path, { map = laneMap(), act = actNow, run = runHandoff, add = addTask, push = pushTasks, now = Date.now(), ledger = loadLedger() } = {}) {
|
|
80
|
+
function dispatch(path, { map = laneMap(), act = actNow, run = runHandoff, add = addTask, push = pushTasks, drop = removeTask, running = runningHandoff, refresh = resupply, tasks = loadTasks, now = Date.now(), ledger = loadLedger() } = {}) {
|
|
57
81
|
const asked = parseHandoffs(readSym(path, "HANDOFF.md")); if (!asked.length) return [];
|
|
58
82
|
const from = { lane: laneOf(path, map), path }, parent = ledger.handoffs.filter((h) => h.to.path === path).pop(), chain = (parent ? parent.chain : 0) + 1;
|
|
59
83
|
const started = [];
|
|
60
84
|
for (const h of asked) {
|
|
61
85
|
const key = createHash("sha1").update(path + "\n" + h.lane.toLowerCase() + "\n" + h.text).digest("hex").slice(0, 16);
|
|
62
|
-
if (ledger.handoffs.some((x) => x.key === key)) continue;
|
|
86
|
+
if (ledger.handoffs.some((x) => x.key === key || (x.was || []).includes(key))) continue;
|
|
87
|
+
const prior = supersedes(h, from, ledger, { running, tasks });
|
|
88
|
+
if (prior) {
|
|
89
|
+
const was = [...(prior.was || []), prior.key];
|
|
90
|
+
if (prior.to.lane === OPS) {
|
|
91
|
+
const r = refresh(prior, h.text, { now, title: `handed over by ${from.lane}`, context: `${from.lane}'s agent (in ${path}) handed this over, and carries on once it's done.` });
|
|
92
|
+
if (r.error) { Object.assign(prior, { status: "error", error: r.error, text: h.text, key, was }); started.push(prior); continue; }
|
|
93
|
+
Object.assign(prior, { text: h.text, key, was, at: now, superseded: (prior.superseded || 0) + 1 });
|
|
94
|
+
} else {
|
|
95
|
+
drop(prior.task);
|
|
96
|
+
const t = add(h.text, prior.to.lane, { after: `(handed over by ${from.lane})` });
|
|
97
|
+
if (t.error) { Object.assign(prior, { status: "error", error: t.error, text: h.text, key, was }); started.push(prior); continue; }
|
|
98
|
+
push({ repo: prior.to.lane });
|
|
99
|
+
const r = run(prior.to.path) || {};
|
|
100
|
+
Object.assign(prior, { text: h.text, key, was, at: now, task: t.id, superseded: (prior.superseded || 0) + 1, status: r.id && !r.busy && !r.blocked ? "started" : "held", ...(r.id ? { job: r.id } : {}) });
|
|
101
|
+
}
|
|
102
|
+
started.push(prior); continue;
|
|
103
|
+
}
|
|
63
104
|
const e = { id: randomBytes(4).toString("hex"), key, from, to: { lane: h.lane, path: "" }, text: h.text, at: now, chain, status: "started" };
|
|
64
105
|
const to = laneTarget(h.lane, map);
|
|
65
106
|
if (chain > MAX_CHAIN) Object.assign(e, { status: "error", error: `${MAX_CHAIN} handovers in a row led here, so this one wasn't started. Do it yourself, or ask the user.` });
|
|
@@ -155,9 +196,11 @@ function lanesTick({ map = laneMap(), act = actNow, run = runHandoff, running =
|
|
|
155
196
|
const ledger = loadLedger();
|
|
156
197
|
let acts = []; try { acts = readdirSync(ACT_DIR).filter((d) => d.startsWith("act-")).map((d) => join(ACT_DIR, d)); } catch {}
|
|
157
198
|
const started = [];
|
|
158
|
-
for (const path of [...Object.values(map), ...acts]) if (existsSync(join(path, ".symbiot", "HANDOFF.md"))) started.push(...dispatch(path, { map, act, run, add, push, now, ledger }));
|
|
199
|
+
for (const path of [...Object.values(map), ...acts]) if (existsSync(join(path, ".symbiot", "HANDOFF.md"))) started.push(...dispatch(path, { map, act, run, add, push, running, now, ledger, ...(tasks ? { tasks: () => tasks } : {}) }));
|
|
159
200
|
const t = tasks || loadTasks(), reported = [];
|
|
160
201
|
for (const e of ledger.handoffs) {
|
|
202
|
+
// a superseded ops run that ended where nothing started its new brief (another process ran it): start it here
|
|
203
|
+
if (e.superseded && !e.reportedAt && e.status !== "error" && e.to.lane === OPS && e.to.path && !running(e.to.path) && lastRunStart(e.to.path) < e.at) { const r = run(e.to.path, { force: true }) || {}; if (r.id && !r.busy) e.job = r.id; }
|
|
161
204
|
if (e.reportedAt || running(e.from.path)) continue; // the one that asked reads it when it's started again, so not mid-run
|
|
162
205
|
const o = outcome(e, { tasks: t, running }); if (!o) continue;
|
|
163
206
|
if (report(e, o, { run, running, now })) { e.reportedAt = now; if (e.status !== "error") e.status = "done"; e.result = o.text; reported.push(e); }
|