symbiot 0.54.2 → 0.54.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +49 -10
- package/adapt.mjs +1 -1
- package/agents.mjs +62 -6
- package/checks.mjs +16 -1
- package/handback.mjs +16 -6
- package/handover.mjs +41 -2
- package/headless.mjs +55 -3
- package/home.mjs +123 -14
- package/index.mjs +41 -6
- package/lanes.mjs +132 -5
- package/package.json +2 -2
- package/post.mjs +310 -41
- package/sandbox.mjs +1 -1
- package/server.mjs +36 -8
- package/ui.mjs +192 -66
- package/watch.mjs +5 -2
package/home.mjs
CHANGED
|
@@ -4,24 +4,28 @@
|
|
|
4
4
|
// folders), always out front;
|
|
5
5
|
// - feeds: what's new on the sites you watch (watch.mjs), sized by how much;
|
|
6
6
|
// - lanes: work handed between agents (lanes.mjs), the latest few.
|
|
7
|
-
// Reports agents wrote up that you haven't read (reports.mjs) are a feed too
|
|
7
|
+
// Reports agents wrote up that you haven't read (reports.mjs) are a feed too, and
|
|
8
|
+
// so is a clash in your company folder that matters (checks.mjs newClashes).
|
|
8
9
|
// The app's sections (Map, Tasks, Agents, Week…) are shaped by adapt.mjs from
|
|
9
10
|
// how you use them; this is the live data around them. Home's talk goes to the
|
|
10
11
|
// same Symbiot as every chat (mind.mjs converse), told what home shows.
|
|
11
12
|
import { join } from "node:path";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { watchBoard } from "./watch.mjs";
|
|
14
16
|
import { pendingReview, pushTasks } from "./tasks.mjs";
|
|
15
|
-
import { agentsList, runHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing } from "./agents.mjs";
|
|
17
|
+
import { agentsList, runHandoff, runningHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent } from "./agents.mjs";
|
|
16
18
|
import { linksState } from "./links.mjs";
|
|
17
19
|
import { knowledgeState } from "./knowledge.mjs";
|
|
18
|
-
import { CONFIG_DIR,
|
|
19
|
-
import { lanesState } from "./lanes.mjs";
|
|
20
|
+
import { CONFIG_DIR, loadTasks } from "./core.mjs";
|
|
21
|
+
import { lanesState, stuckHandovers, allowHandover, skipHandover } from "./lanes.mjs";
|
|
20
22
|
import { converse, actIn, actNow, taskIn } from "./mind.mjs";
|
|
21
23
|
import { repoPathMap } from "./scan.mjs";
|
|
22
24
|
import { resolveProvider } from "./ai.mjs";
|
|
23
25
|
import { reportsNews } from "./reports.mjs";
|
|
24
|
-
import { awaitingState } from "./handback.mjs";
|
|
26
|
+
import { awaitingState, inboxSight } from "./handback.mjs";
|
|
27
|
+
import { newClashes } from "./checks.mjs";
|
|
28
|
+
import { postsState } from "./post.mjs";
|
|
25
29
|
|
|
26
30
|
const KEEP = 15000; // the slower reads (git per repo awaiting review) are cached this long
|
|
27
31
|
let cached = null;
|
|
@@ -46,6 +50,41 @@ function displayName(path, folder) {
|
|
|
46
50
|
// The lane a folder is: the repo it is (by the scan's map), or an ops run.
|
|
47
51
|
const laneOfPath = (path, map) => Object.keys(map || {}).find((n) => map[n] === path) || (String(path || "").startsWith(RUNS) ? "" : null);
|
|
48
52
|
|
|
53
|
+
// A run that failed shows for a day, until you run it again or skip it; one
|
|
54
|
+
// that's been followed by another run in its folder is past.
|
|
55
|
+
const FAILED_FOR = 86400000, dropped = new Set();
|
|
56
|
+
const firstLine = (t) => String(t || "").split("\n").find((l) => l.trim()) || "";
|
|
57
|
+
// What stopped and can't go on without you, as questions: a handover that couldn't
|
|
58
|
+
// start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"), a run
|
|
59
|
+
// that ended waiting on you (agents.mjs needsOf: a draft for your OK, a sudo step,
|
|
60
|
+
// a step your answer left you) with what to check first, and a run that ended in an
|
|
61
|
+
// error. Each is answered on its blob (homeAnswer), on Home and on its lane in
|
|
62
|
+
// Tasks. repo: its lane ("" for an ops run).
|
|
63
|
+
function troubles({ stuck = [], list = [], map = {}, named = displayName, now = Date.now() } = {}) {
|
|
64
|
+
const out = [], seen = new Set();
|
|
65
|
+
for (const t of stuck) {
|
|
66
|
+
const lane = t.from.lane === RUNS_LANE ? "" : t.from.lane;
|
|
67
|
+
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",
|
|
68
|
+
title: `${lane || "An agent run"} is stuck`, sub: plain(firstLine(t.text), 90), q: t.q, options: t.options, why: plain(t.error, 160), shape: "agents" });
|
|
69
|
+
}
|
|
70
|
+
for (const a of list) {
|
|
71
|
+
if (seen.has(a.path)) continue; seen.add(a.path); // the newest run in each folder (agentsList: newest first)
|
|
72
|
+
if (a.status === "running" || (a.ask && a.ask.questions && a.ask.questions.length)) continue; // at work, or it asked: that's its question
|
|
73
|
+
const lane = laneOfPath(a.path, map), repo = lane || "", nm = repo ? named(map[lane], repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, "").replace(/^\W*What['’]s needed:\W*/i, "").replace(/^./, (c) => c.toUpperCase()), 60);
|
|
74
|
+
const n = a.needs;
|
|
75
|
+
if (n && !dropped.has("needs:" + a.id)) {
|
|
76
|
+
const ok = n.kind === "approve";
|
|
77
|
+
out.push({ kind: "ask", fix: "needs", id: "needs:" + a.id, path: a.path, repo, name: nm, title: ok ? `${nm} waits for your OK` : `${nm} needs you`, sub: plain(n.label || n.what, 90),
|
|
78
|
+
q: n.what + (n.check ? ` Check first: ${n.check}` : ""), options: ok ? ["Go ahead (recommended)", "Skip"] : ["Done it (recommended)", "Skip"], shape: "agents" });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (a.status !== "failed" || dropped.has(a.id) || (a.endedAt && now - a.endedAt > FAILED_FOR)) continue;
|
|
82
|
+
const said = plain(String(a.tail || "").split(/\n+/).filter((l) => l.trim()).pop() || "", 140);
|
|
83
|
+
out.push({ kind: "ask", fix: "failed", id: "failed:" + a.id, path: a.path, repo, name: nm, title: `${nm}'s run stopped`, sub: said || `it exited with code ${a.exitCode}`,
|
|
84
|
+
q: `Its run stopped with an error${said ? `: ${said}` : ` (exit code ${a.exitCode})`}. Run it again?`, options: ["Run it again (recommended)", "Skip"], shape: "agents" });
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
49
88
|
// { you: [{ kind, id, title, sub, shape, … }], feeds: [{ id, title, sub, count, shape }],
|
|
50
89
|
// lanes: [{ from, to, text, status, times }], working: n, at }
|
|
51
90
|
// An ask carries what its blob on home needs: the lane (repo, or "" for an ops
|
|
@@ -60,11 +99,17 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
60
99
|
const connected = deps.connected || (() => !!resolveProvider());
|
|
61
100
|
const repos = deps.repos || (() => { try { return repoPathMap(); } catch { return {}; } });
|
|
62
101
|
const waits = deps.waits || (() => { try { return awaitingState().waits; } catch { return []; } });
|
|
63
|
-
|
|
64
|
-
|
|
102
|
+
const clashes = deps.clashes || (() => { try { return newClashes(); } catch { return []; } });
|
|
103
|
+
// can a reply be noticed? Email on (mail on this computer), or an inbox watched and signed in
|
|
104
|
+
const seesInbox = deps.seesInbox || (() => { try { return inboxSight(); } catch { return true; } });
|
|
65
105
|
// the agent the handoff command runs, gone from this computer; Claude connectors linked here but signed out
|
|
66
106
|
const agentGone = deps.agentGone || (() => { try { return handoffCmd() ? agentMissing() : ""; } catch { return ""; } });
|
|
67
107
|
const signedOut = deps.signedOut || (() => { try { const c = connectorsInfo(); return c.claude ? (c.links || []).filter((x) => x.connector && !x.ready) : []; } catch { return []; } });
|
|
108
|
+
const stuck = deps.stuck || (() => { try { return stuckHandovers({ now }); } catch { return []; } });
|
|
109
|
+
const agentCmd = deps.agentCmd || (() => { try { return handoffCmd(); } catch { return "x"; } });
|
|
110
|
+
const offer = deps.pickAgent || (() => { try { return pickAgent(); } catch { return null; } });
|
|
111
|
+
// Marketing (its own section, off the Dashboard) once posts can be drafted, or there are some
|
|
112
|
+
const marketing = deps.marketing || (() => { try { const p = postsState(); return !!(p.canDraft || p.posts.length || p.done.length); } catch { return false; } });
|
|
68
113
|
const named = deps.name || displayName;
|
|
69
114
|
const you = [], map = repos() || {};
|
|
70
115
|
// What Symbiot works through (an AI, your agent, the connectors runs use) shows
|
|
@@ -74,6 +119,9 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
74
119
|
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" });
|
|
75
120
|
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" });
|
|
76
121
|
if (!Object.keys(map).length) you.push({ kind: "setup", id: "setup:folders", title: "Show me your work", sub: "where your repos are", shape: "settings", focus: "work" });
|
|
122
|
+
// no agent yet: Send to repos and Go would only write TASKS.md files nothing runs, so
|
|
123
|
+
// Home didn't say "All handled" truthfully. One click for the one on this computer.
|
|
124
|
+
if (!agentCmd()) { const p = offer(); 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` : "the coding agent that takes your tasks", shape: "settings", focus: "agent", ...(p ? { pick: { name: p.name, tmpl: p.tmpl } } : {}) }); }
|
|
77
125
|
for (const r of pending()) {
|
|
78
126
|
if (!r.path || r.running) continue;
|
|
79
127
|
const n = (r.tasks || []).length, files = (r.files || []).length;
|
|
@@ -89,19 +137,75 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
89
137
|
you.push({ kind: "ask", id: "ask:" + a.path, path: a.path, repo, name: repo ? named(lane ? map[lane] : a.path, repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, ""), 60),
|
|
90
138
|
title: `${a.name} asks`, sub: String(q.q || "").slice(0, 90), q: String(q.q || ""), options: (q.options || []).slice(0, 2), ...(qs.length > 1 ? { more: qs.length - 1 } : {}), shape: "agents" });
|
|
91
139
|
}
|
|
140
|
+
// What stopped and can't go on without you, as a question too: a handover that
|
|
141
|
+
// couldn't start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"),
|
|
142
|
+
// and a run that ended in an error. Answered on its blob (homeAnswer).
|
|
143
|
+
you.push(...troubles({ stuck: stuck(), list, map, named, now }));
|
|
92
144
|
// A run is waiting on an emailed reply that nothing will see: say so, once,
|
|
93
|
-
// until an inbox is watched or Email is on (handback.mjs looks in either).
|
|
94
|
-
if (waits().some((w) => w.status === "waiting")
|
|
145
|
+
// until an inbox is watched (and signed in) or Email is on (handback.mjs looks in either).
|
|
146
|
+
if (waits().some((w) => w.status === "waiting")) {
|
|
147
|
+
const s = seesInbox(), sight = s && typeof s === "object" ? s : { sees: !!s, signedOut: [] };
|
|
148
|
+
if (!sight.sees) you.push(sight.signedOut.length
|
|
149
|
+
? { kind: "setup", id: "setup:inbox", signin: true, title: `Sign in to ${sight.signedOut[0]} again`, sub: "it's signed out, so I won't notice their reply", shape: "settings", focus: "links" }
|
|
150
|
+
: { kind: "setup", id: "setup:inbox", title: "Let me see your inbox", sub: "so I notice their reply", shape: "settings", focus: "links" });
|
|
151
|
+
}
|
|
95
152
|
const feeds = ((board() || {}).cards || []).filter((c) => c.count > 0).map((c) => ({ id: "feed:" + c.id, title: c.name, sub: c.label, count: c.count, shape: "board" }));
|
|
153
|
+
// a clash in your files that matters (checks.mjs: high), first under Watching, until you've opened it
|
|
154
|
+
const clash = clashes();
|
|
155
|
+
if (clash.length) feeds.unshift({ id: "feed:clash", title: "Where your files disagree", sub: `${clash.length} clash${clash.length > 1 ? "es" : ""} to look at`, latest: plain(clash[0].text, 140), count: clash.length, shape: "settings" });
|
|
96
156
|
const rep = reports();
|
|
97
157
|
if (rep.count) feeds.push({ id: "feed:reports", title: "Reports", sub: `${rep.count} unread`, latest: rep.latest, count: rep.count, shape: "reports" });
|
|
98
158
|
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 } : {}) }));
|
|
99
159
|
const working = list.filter((a) => a.status === "running").length;
|
|
100
|
-
const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, at: now };
|
|
160
|
+
const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, marketing: marketing(), at: now };
|
|
101
161
|
if (!deps.board) cached = out;
|
|
102
162
|
return out;
|
|
103
163
|
}
|
|
104
164
|
|
|
165
|
+
// An answer on a stuck handover's, a waiting run's or a failed run's blob. pick: the
|
|
166
|
+
// option's place (0 goes ahead, 1 skips); text: your own words, which go ahead with
|
|
167
|
+
// them unless they say no. The handover's run (lanes.mjs allowHandover) reports back
|
|
168
|
+
// to the agent that asked; a run waiting on your OK or your step starts again with
|
|
169
|
+
// "go ahead" or "done" in its ANSWERS.md (skipped, it doesn't ask again); a failed
|
|
170
|
+
// run starts again in its folder, with your words in its ANSWERS.md. { ok, said } or { error }.
|
|
171
|
+
const SAYS_NO = /^\s*(skip|no|nope|don'?t|do not|cancel|leave it|never ?mind|stop)\b/i;
|
|
172
|
+
// An answer, in the folder's ANSWERS.md, for the run that picks it up.
|
|
173
|
+
function noteAnswer(path, heading, text) {
|
|
174
|
+
const f = join(path, ".symbiot", "ANSWERS.md"); let had = ""; try { had = readFileSync(f, "utf8"); } catch {}
|
|
175
|
+
try { writeFileSync(f, `${(had || "# Answers\n").replace(/\s*$/, "")}\n\n### ${String(heading).replace(/\s+/g, " ").trim()}\n${text}\n_answered ${new Date().toISOString().slice(0, 10)}_\n`); } catch {}
|
|
176
|
+
}
|
|
177
|
+
function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
|
|
178
|
+
id = String(id || ""); text = String(text || "").trim().slice(0, 2000);
|
|
179
|
+
const go = text ? !SAYS_NO.test(text) : Number(pick) === 0, ref = id.replace(/^\w+:/, "");
|
|
180
|
+
cached = null;
|
|
181
|
+
if (id.startsWith("stuck:")) {
|
|
182
|
+
if (!go) { const r = (deps.skip || skipHandover)(ref); return r.error ? r : { ok: true, said: "Skipped. Its agent knows." }; }
|
|
183
|
+
const r = (deps.allow || allowHandover)(ref, { note: text }); if (r.error) return r;
|
|
184
|
+
return { ok: true, said: `Started a run on it${r.dirs && r.dirs.length ? `, allowed into ${r.dirs.map((d) => (d.startsWith(homedir() + "/") ? "~" + d.slice(homedir().length) : d)).join(" and ")}` : ""}. What it does goes back to the agent that asked.` };
|
|
185
|
+
}
|
|
186
|
+
if (id.startsWith("failed:")) {
|
|
187
|
+
const a = (deps.agents || agentsList)().find((x) => x.id === ref); if (!a) return { error: "That run isn't here any more." };
|
|
188
|
+
if (!go) { dropped.add(ref); return { ok: true, said: "Skipped." }; }
|
|
189
|
+
if ((deps.running || runningHandoff)(a.path)) return { ok: true, said: "An agent is already at work there." };
|
|
190
|
+
if (text) noteAnswer(a.path, "Your last run stopped with an error", text);
|
|
191
|
+
const e = (deps.run || runHandoff)(a.path, { force: true }); dropped.add(ref);
|
|
192
|
+
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." };
|
|
193
|
+
}
|
|
194
|
+
if (id.startsWith("needs:")) {
|
|
195
|
+
const a = (deps.agents || agentsList)().find((x) => x.id === ref), n = a && a.needs;
|
|
196
|
+
if (!n) return { error: "That isn't waiting on you any more." };
|
|
197
|
+
const settle = deps.settle || settleNeeds;
|
|
198
|
+
if (!go) { settle(a.path, n.key); dropped.add(id); return { ok: true, said: "Skipped. It won't ask again." }; }
|
|
199
|
+
if ((deps.running || runningHandoff)(a.path)) return { ok: true, said: "An agent is already at work there." };
|
|
200
|
+
const said = n.kind === "approve" ? `Go ahead: ${text || "do it as you had it, once you've checked what you flagged"}.` : `Done: the user did it${text ? ` (${text})` : ""}. Check it worked, then carry on.`;
|
|
201
|
+
noteAnswer(a.path, n.what, said);
|
|
202
|
+
const e = (deps.run || runHandoff)(a.path, { force: true });
|
|
203
|
+
if (e && e.id && !e.blocked) { settle(a.path, n.key); dropped.add(id); }
|
|
204
|
+
return e && e.id && !e.blocked ? { ok: true, said: n.kind === "approve" ? "Its agent is going ahead." : "Its agent is checking and carrying on.", rerun: e.id } : { error: (e && e.note) || "It didn't start. Check your agent in Settings." };
|
|
205
|
+
}
|
|
206
|
+
return { error: "Answer that one on its block in Agents." };
|
|
207
|
+
}
|
|
208
|
+
|
|
105
209
|
// Settings' first steps, in the order they matter (FIRST-RUN-AUDIT.md: a new user
|
|
106
210
|
// found Settings one long page with nothing saying what comes first). Each is
|
|
107
211
|
// done or not from what's set up, so it ticks itself; the company folder is
|
|
@@ -136,7 +240,7 @@ function firstSteps({ deps = {} } = {}) {
|
|
|
136
240
|
// One line per thing home shows, for the talk's context.
|
|
137
241
|
function homeContext(h) {
|
|
138
242
|
return [
|
|
139
|
-
`Only the user can do (${h.you.length}):`, ...h.you.map((y) => `- ${y.title}: ${y.sub}`),
|
|
243
|
+
`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(" / ")}` : ""})` : ""}`),
|
|
140
244
|
`New on what they watch:`, ...(h.feeds.length ? h.feeds.map((f) => `- ${f.title}: ${f.sub}${f.latest ? ` (latest: ${f.latest})` : ""}`) : ["- nothing new"]),
|
|
141
245
|
`Agents working: ${h.working}. Recent handovers:`, ...(h.lanes.length ? h.lanes.map((l) => `- ${l.from} → ${l.to}: ${l.text} (${l.status}${l.times ? `, handed over ${l.times} times` : ""})`) : ["- none"]),
|
|
142
246
|
].join("\n");
|
|
@@ -218,6 +322,11 @@ function workScene({ deps = {} } = {}) {
|
|
|
218
322
|
for (const q of qs) { p.qs = p.qs || []; if (p.qs.length < 3) p.qs.push({ q: q.q, options: (q.options || []).slice(0, 3), path: a.path }); }
|
|
219
323
|
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 }); }
|
|
220
324
|
}
|
|
325
|
+
const stuck = deps.stuck || (() => { try { return stuckHandovers(); } catch { return []; } });
|
|
326
|
+
for (const t of troubles({ stuck: stuck(), list: AG, map, named })) {
|
|
327
|
+
const p = at(t.repo || RUNS_LANE); p.asks++; p.qs = p.qs || [];
|
|
328
|
+
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 });
|
|
329
|
+
}
|
|
221
330
|
for (const p of Object.values(by)) { p.lit = !!(p.asks || p.ready); if (isParked(p.repo)) p.parked = true; }
|
|
222
331
|
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));
|
|
223
332
|
return { running, ready, waiting: waiting.slice(0, 12), waitingCount: waiting.length, canGo: waiting.filter((w) => !w.busy && !w.parked).length, projects: projects.slice(0, 24), projectCount: projects.length };
|
|
@@ -234,4 +343,4 @@ function workGo({ push = pushTasks, run = runHandoff } = {}) {
|
|
|
234
343
|
return { started, queued, repos, ...(parked.length ? { parked } : {}), ...(r.unresolved && r.unresolved.length ? { unresolved: r.unresolved.map((u) => u.name) } : {}) };
|
|
235
344
|
}
|
|
236
345
|
|
|
237
|
-
export { homeState, homeContext, homeAsk, workScene, workGo, displayName, firstSteps };
|
|
346
|
+
export { homeState, homeContext, homeAsk, homeAnswer, workScene, workGo, displayName, firstSteps };
|
package/index.mjs
CHANGED
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
|
|
26
26
|
import { spawn } from "node:child_process";
|
|
27
27
|
import { homedir } from "node:os";
|
|
28
|
-
import { join } from "node:path";
|
|
29
|
-
import { realpathSync } from "node:fs";
|
|
28
|
+
import { join, basename } from "node:path";
|
|
29
|
+
import { realpathSync, readFileSync } from "node:fs";
|
|
30
30
|
import { createInterface } from "node:readline";
|
|
31
31
|
import { fileURLToPath } from "node:url";
|
|
32
32
|
import { EMBEDDED_UI } from "./ui.mjs";
|
|
@@ -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, openUrl } from "./post.mjs";
|
|
44
|
+
import { KIND_LABEL as POST_KIND, PATHS as POST_PATHS, draftPosts, postsState, postLog, approvePost, editPost, skipPost, voiceFromLinkedIn, openUrl, addMedia as addPostMedia, removeMedia as removePostMedia, mediaDir as postMediaDir, pictureOfPage, clipOfPage } from "./post.mjs";
|
|
45
45
|
import { startApp, updateCmd, isAppRunningWeekly } from "./server.mjs";
|
|
46
46
|
import { listReports, readReport } from "./reports.mjs";
|
|
47
47
|
import { runSandbox } from "./sandbox.mjs";
|
|
@@ -621,18 +621,22 @@ async function cmdPhone() {
|
|
|
621
621
|
// Nothing is ever posted by Symbiot: approve copies one post to your clipboard and
|
|
622
622
|
// gives LinkedIn's share box, where you paste it and post it yourself.
|
|
623
623
|
async function cmdPost() {
|
|
624
|
-
const [sub = "draft", a1, a2] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--since");
|
|
624
|
+
const [sub = "draft", a1, a2] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--since" && all[i - 1] !== "--seconds");
|
|
625
625
|
const fail = (msg) => { console.log(c.y(msg)); process.exitCode = 1; };
|
|
626
626
|
const show = (p) => {
|
|
627
627
|
console.log(`\n${c.b(POST_KIND[p.kind] || p.kind)} ${c.d(`${p.id} · ${p.status}${p.edited ? " · edited" : ""}`)}\n${p.text}`);
|
|
628
628
|
if (p.sources && p.sources.length) console.log(c.d(" from git: " + p.sources.slice(0, 4).map((s) => s.replace(/^\[\d+\]\s*/, "")).join("\n ") + (p.sources.length > 4 ? `\n …and ${p.sources.length - 4} more` : "")));
|
|
629
|
+
for (const m of p.media || []) console.log(c.d(` ${m.kind} ${m.id}: `) + join(postMediaDir(p.id), m.file) + c.d(m.url ? ` (${m.from === "clip" ? "clip" : "picture"} of ${m.url})` : ""));
|
|
630
|
+
if (p.show && !(p.media || []).length) console.log(c.d(" picture idea: " + p.show));
|
|
629
631
|
};
|
|
630
632
|
if (sub === "draft" || sub === "new") {
|
|
631
633
|
const r = await draftPosts({ days: SINCE_WEEK || 7 });
|
|
632
634
|
if (r.error) return fail(r.error);
|
|
633
635
|
for (const p of r.posts) show(p);
|
|
636
|
+
if (r.retried) console.log("\n" + c.d("The first try kept none (each claimed what git doesn't show), so Symbiot tried again by itself."));
|
|
637
|
+
if (r.pictures) console.log(c.d(`${r.pictures === 1 ? "One draft names" : `${r.pictures} drafts name`} a screen of an app you run here, so ${r.pictures === 1 ? "it has its picture" : "they have their pictures"} already: keep or remove (symbiot post remove <id> <media id>).`));
|
|
634
638
|
if (r.dropped.length) console.log("\n" + c.y(`Dropped ${r.dropped.length}: `) + r.dropped.map((x) => `${POST_KIND[x.kind] || x.kind} (${x.cited ? "claimed " + x.unsupported.join(", ") + ", which git doesn't show" : "cited nothing from git"})`).join("; "));
|
|
635
|
-
console.log("\n" + c.d(`${r.posts.length} draft${r.posts.length === 1 ? "" : "s"} from ${r.facts} things git shows this week, in the voice of your ${r.voice} example${r.voice === 1 ? "" : "s"}. They wait
|
|
639
|
+
console.log("\n" + c.d(`${r.posts.length} draft${r.posts.length === 1 ? "" : "s"} from ${r.facts} things git shows this week, in the voice of your ${r.voice} example${r.voice === 1 ? "" : "s"}. They wait under Marketing in the app too.`));
|
|
636
640
|
console.log(c.d("Nothing is posted until you approve one: symbiot post approve <id> (or edit / skip)."));
|
|
637
641
|
return;
|
|
638
642
|
}
|
|
@@ -648,10 +652,33 @@ async function cmdPost() {
|
|
|
648
652
|
console.log(`${c.g("✓")} Approved.\n\n${r.post.text}\n`);
|
|
649
653
|
console.log(r.copied ? `${c.g("✓")} Copied to your clipboard (${r.copied}).` : c.y("No clipboard tool found (wl-copy, xclip, xsel, pbcopy or clip): copy it from above."));
|
|
650
654
|
console.log(`Paste it into LinkedIn's share box: ${c.b(r.share)}` + (has("open") ? "" : c.d(" (--open opens it)")));
|
|
651
|
-
if (has("open")
|
|
655
|
+
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)")));
|
|
656
|
+
if (has("open")) { openUrl(r.share); if (r.folder) openUrl(r.folder); }
|
|
652
657
|
console.log(c.d(r.note));
|
|
653
658
|
return;
|
|
654
659
|
}
|
|
660
|
+
// pictures and video on a draft: a file of yours, or a picture or clip of a page
|
|
661
|
+
if (sub === "add") {
|
|
662
|
+
if (!a2) return fail("Give the draft and the file: symbiot post add <id> <picture or video>");
|
|
663
|
+
let data; try { data = readFileSync(a2); } catch (e) { return fail(`Can't read ${a2}: ${e.message}`); }
|
|
664
|
+
const r = addPostMedia(a1, data, { name: basename(a2) });
|
|
665
|
+
if (r.error) return fail(r.error);
|
|
666
|
+
show(r.post); return;
|
|
667
|
+
}
|
|
668
|
+
if (sub === "page") {
|
|
669
|
+
if (!a2) return fail("Give the draft and the page: symbiot post page <id> <web address> [--clip] [--seconds 8]");
|
|
670
|
+
const clip = has("clip"), i = argv.indexOf("--seconds"), seconds = i >= 0 ? Number(argv[i + 1]) || 8 : 8;
|
|
671
|
+
console.log(c.d(clip ? `Recording ${seconds} s of ${a2}…` : `Taking a picture of ${a2}…`));
|
|
672
|
+
// through the app when it runs: its hidden browser and this one share a profile
|
|
673
|
+
const r = (await viaApp("/api/posts/media/page", { id: a1, url: a2, clip, seconds })) || await (clip ? clipOfPage(a1, a2, { seconds }) : pictureOfPage(a1, a2));
|
|
674
|
+
if (r.error) return fail(r.error);
|
|
675
|
+
show(r.post); return;
|
|
676
|
+
}
|
|
677
|
+
if (sub === "unadd" || sub === "remove") {
|
|
678
|
+
const r = removePostMedia(a1, a2);
|
|
679
|
+
if (r.error) return fail(r.error);
|
|
680
|
+
console.log(`${c.g("✓")} Taken off ${r.post.id}.`); return;
|
|
681
|
+
}
|
|
655
682
|
if (sub === "edit") {
|
|
656
683
|
if (!a2) return fail('Give the new text: symbiot post edit <id> "the post"');
|
|
657
684
|
const r = editPost(a1, a2);
|
|
@@ -685,6 +712,14 @@ async function cmdPost() {
|
|
|
685
712
|
copy it to your clipboard, and give (--open:
|
|
686
713
|
open) LinkedIn's share box to paste it into
|
|
687
714
|
symbiot post edit <id> "text" change a draft's words
|
|
715
|
+
symbiot post add <id> <file> put a picture or video of yours on a draft
|
|
716
|
+
(PNG, JPG, GIF, MP4, MOV, WebM)
|
|
717
|
+
symbiot post page <id> <page> [--clip] [--seconds 8]
|
|
718
|
+
a picture of a web page (localhost too) on a
|
|
719
|
+
draft; --clip records a short video of it,
|
|
720
|
+
scrolling down (needs ffmpeg)
|
|
721
|
+
symbiot post remove <id> <media id>
|
|
722
|
+
take a picture or video off a draft
|
|
688
723
|
symbiot post skip <id> drop a draft
|
|
689
724
|
symbiot post log everything drafted, edited, approved, skipped
|
|
690
725
|
symbiot post voice [--linkedin] your example posts (voice.md), or read your
|
package/lanes.mjs
CHANGED
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
// it starts when it's free), done (reported back) or error (reported back too).
|
|
16
16
|
// chain: how many handovers in a row led here; past MAX_CHAIN it stops, so two
|
|
17
17
|
// lanes can't hand the same thing back and forth forever.
|
|
18
|
-
import { join, basename } from "node:path";
|
|
19
|
-
import {
|
|
18
|
+
import { join, basename, dirname, resolve, relative } from "node:path";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, chmodSync, statSync } from "node:fs";
|
|
20
21
|
import { createHash, randomBytes } from "node:crypto";
|
|
22
|
+
import { execFile } from "node:child_process";
|
|
21
23
|
import { CONFIG_DIR, loadTasks, clipWords } from "./core.mjs";
|
|
22
|
-
import { runHandoff, runningHandoff, waitingFor, agentQuestions } from "./agents.mjs";
|
|
24
|
+
import { runHandoff, runningHandoff, waitingFor, agentQuestions, findOrcaCli, installAllowlist } from "./agents.mjs";
|
|
23
25
|
import { addTask, pushTasks } from "./tasks.mjs";
|
|
24
26
|
import { actNow } from "./mind.mjs";
|
|
25
27
|
import { repoPathMap } from "./scan.mjs";
|
|
@@ -149,7 +151,7 @@ function report(e, o, { run = runHandoff, running = runningHandoff, waiting = wa
|
|
|
149
151
|
|
|
150
152
|
// One pass: start what's been handed over, from every lane, then report back
|
|
151
153
|
// what's done. The app runs it every 20 seconds.
|
|
152
|
-
function lanesTick({ map = repoPathMap(), act = actNow, run = runHandoff, running = runningHandoff, add = addTask, push = pushTasks, tasks, now = Date.now() } = {}) {
|
|
154
|
+
function lanesTick({ map = repoPathMap(), act = actNow, run = runHandoff, running = runningHandoff, add = addTask, push = pushTasks, tasks, now = Date.now(), relink = orcaRelink } = {}) {
|
|
153
155
|
const ledger = loadLedger();
|
|
154
156
|
let acts = []; try { acts = readdirSync(ACT_DIR).filter((d) => d.startsWith("act-")).map((d) => join(ACT_DIR, d)); } catch {}
|
|
155
157
|
const started = [];
|
|
@@ -161,8 +163,133 @@ function lanesTick({ map = repoPathMap(), act = actNow, run = runHandoff, runnin
|
|
|
161
163
|
if (report(e, o, { run, running, now })) { e.reportedAt = now; if (e.status !== "error") e.status = "done"; e.result = o.text; reported.push(e); }
|
|
162
164
|
}
|
|
163
165
|
if (started.length || reported.length) saveLedger(ledger);
|
|
166
|
+
if (reported.length) relink({ map }).catch(() => {}); // a handover may have renamed a lane's folder
|
|
164
167
|
return { started, reported };
|
|
165
168
|
}
|
|
169
|
+
|
|
170
|
+
// ---- a handover that couldn't start: yours to unblock, on Home ---------------------
|
|
171
|
+
// A handover that errored (an ops run limited to its folder handed "needs a run that
|
|
172
|
+
// can edit ~/Company" to ops, its own lane; a lane that doesn't exist; too long a
|
|
173
|
+
// chain) went back to the agent that asked, and stopped there: Home said nothing
|
|
174
|
+
// needed you. Now it's a question on Home (homeState) for STUCK_FOR, until you
|
|
175
|
+
// answer it or something like it goes through another way. Allow starts it as a
|
|
176
|
+
// run of its own, allowed to read, edit and move files in the folders it names
|
|
177
|
+
// (that run only: its .claude/settings.local.json), and its result goes back to the
|
|
178
|
+
// agent that asked, as any handover's does; Skip lets it go.
|
|
179
|
+
const STUCK_FOR = 3 * 86400000;
|
|
180
|
+
const tilde = (p) => { const h = homedir(); return p === h ? "~" : p.startsWith(h + "/") ? "~" + p.slice(h.length) : p; };
|
|
181
|
+
// Two handovers about the same thing: most of the shorter one's words in the other.
|
|
182
|
+
function alike(a, b) {
|
|
183
|
+
const x = keyWords(a), y = keyWords(b), [s, l] = x.size <= y.size ? [x, y] : [y, x]; if (s.size < 3) return false;
|
|
184
|
+
let n = 0; for (const w of s) if (l.has(w)) n++;
|
|
185
|
+
return n / s.size >= 0.5;
|
|
186
|
+
}
|
|
187
|
+
// The folders a handover names that a run would need: ones on this computer, not
|
|
188
|
+
// the asking run's own, not Symbiot's, not your home itself or a hidden one in it
|
|
189
|
+
// (~/.ssh). A file named in one counts as the folder it's in. Outermost only, 3 at most.
|
|
190
|
+
function namedDirs(text, own = "", { exists = (p) => { try { return statSync(p); } catch { return null; } } } = {}) {
|
|
191
|
+
const home = homedir(), out = [];
|
|
192
|
+
const roots = ["~", "/home", "/Users", "/srv", "/opt", "/mnt", "/media", home].map((r) => r.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")).join("|");
|
|
193
|
+
for (const m of String(text || "").matchAll(new RegExp(`(?:^|[\\s\`'"(])((?:${roots})\\/[^\\s\`'",;)]+)`, "g"))) {
|
|
194
|
+
let p = resolve(m[1].replace(/^~(?=\/)/, home).replace(/[.:]+$/, "")); const st = exists(p); if (!st) continue;
|
|
195
|
+
if (!st.isDirectory()) p = dirname(p);
|
|
196
|
+
const rel = relative(home, p);
|
|
197
|
+
if (p === home || p === "/" || (!rel.startsWith("..") && rel.split("/")[0].startsWith(".")) || p.startsWith(CONFIG_DIR) || (own && (p === own || p.startsWith(own + "/")))) continue;
|
|
198
|
+
out.push(p);
|
|
199
|
+
}
|
|
200
|
+
return [...new Set(out)].filter((p, i, all) => !all.some((q) => q !== p && p.startsWith(q + "/"))).slice(0, 3);
|
|
201
|
+
}
|
|
202
|
+
// It went through since another way: the same thing handed over again (or done,
|
|
203
|
+
// after it), or the run that asked has run again and ticked all it had.
|
|
204
|
+
function pastIt(e, hs) {
|
|
205
|
+
if (hs.some((x) => x !== e && x.status !== "error" && Math.max(x.at || 0, x.reportedAt || 0) > e.at && alike(x.text, e.text))) return true;
|
|
206
|
+
const md = readSym(e.from.path, "TASKS.md");
|
|
207
|
+
return lastRunStart(e.from.path) > e.at && /^- \[x\]/im.test(md) && !/^\s*- \[ \]/m.test(md);
|
|
208
|
+
}
|
|
209
|
+
// The errored handovers that still wait on you, newest first:
|
|
210
|
+
// [{ id, from, text, error, at, dirs, q, options }].
|
|
211
|
+
function stuckHandovers({ ledger = loadLedger(), now = Date.now(), dirsOf = namedDirs } = {}) {
|
|
212
|
+
const hs = ledger.handoffs, out = [];
|
|
213
|
+
hs.forEach((e) => {
|
|
214
|
+
if (e.status !== "error" || e.skipped || now - (e.at || 0) > STUCK_FOR) return;
|
|
215
|
+
if (pastIt(e, hs)) return;
|
|
216
|
+
const dirs = dirsOf(e.text, e.from.path);
|
|
217
|
+
out.unshift({ id: e.id, from: e.from, text: e.text, error: e.error || "", at: e.at, dirs,
|
|
218
|
+
q: dirs.length ? `Allow this run access to ${dirs.map(tilde).join(" and ")}?` : `${e.from.lane}'s handover didn't start (${String(e.error || "").replace(/[.:]\s.*$|\.$/, "")}). Start it as a run of its own?`,
|
|
219
|
+
options: [dirs.length ? "Allow (recommended)" : "Start it (recommended)", "Skip"] });
|
|
220
|
+
});
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
// The rules a run gets for the folders you allowed it: read, edit and move files
|
|
224
|
+
// there (git mv too), nothing wide.
|
|
225
|
+
const dirRules = (dirs) => dirs.flatMap((d) => [`Read(/${d}/**)`, `Edit(/${d}/**)`, `Write(/${d}/**)`]).concat(dirs.length ? ["Bash(mv:*)", "Bash(git mv:*)", "Bash(mkdir:*)", "Bash(ls:*)", "Bash(find:*)"] : []);
|
|
226
|
+
function grantDirs(dir, dirs) {
|
|
227
|
+
if (!dirs.length) return null;
|
|
228
|
+
try { writeFileSync(join(dir, ".symbiot", "allowlist.proposed.json"), JSON.stringify({ permissions: { allow: dirRules(dirs), additionalDirectories: dirs } }, null, 2) + "\n"); } catch { return null; }
|
|
229
|
+
return installAllowlist(dir);
|
|
230
|
+
}
|
|
231
|
+
// Allow (or Start it): a run of its own on what was handed over, with what you
|
|
232
|
+
// said added, allowed into the folders it names. The ledger's entry becomes that
|
|
233
|
+
// handover, started, so its result goes back to the agent that asked. { ok, job, dirs } or { error }.
|
|
234
|
+
function allowHandover(id, { note = "", act = actNow, run = runHandoff, now = Date.now(), dirsOf = namedDirs } = {}) {
|
|
235
|
+
const ledger = loadLedger(), e = ledger.handoffs.find((x) => x.id === id);
|
|
236
|
+
if (!e || e.status !== "error" || e.skipped) return { error: "That handover isn't waiting on you any more." };
|
|
237
|
+
const dirs = dirsOf(e.text, e.from.path), said = String(note || "").trim().slice(0, 2000);
|
|
238
|
+
const r = act(e.text + (said ? `\n\nThe user said: ${said}` : ""), { now, title: `handed over by ${e.from.lane}`,
|
|
239
|
+
context: `${e.from.lane}'s agent (in ${e.from.path}) handed this over, and carries on once it's done.${dirs.length ? ` The user allowed this run to read, edit and move files in ${dirs.join(", ")}.` : ""}`,
|
|
240
|
+
run: (dir, o) => { grantDirs(dir, dirs); return run(dir, o); } });
|
|
241
|
+
if (!r || r.error) return { error: (r && r.error) || "The run didn't start." };
|
|
242
|
+
Object.assign(e, { status: "started", to: { lane: OPS, path: r.dir }, job: r.job, at: now, allowedAt: now });
|
|
243
|
+
delete e.error; delete e.reportedAt; delete e.result;
|
|
244
|
+
saveLedger(ledger);
|
|
245
|
+
return { ok: true, job: r.job, dirs };
|
|
246
|
+
}
|
|
247
|
+
// Skip: it stops asking, and the agent that handed it over reads that you skipped it.
|
|
248
|
+
function skipHandover(id, { now = Date.now() } = {}) {
|
|
249
|
+
const ledger = loadLedger(), e = ledger.handoffs.find((x) => x.id === id);
|
|
250
|
+
if (!e || e.status !== "error") return { error: "That handover isn't waiting on you any more." };
|
|
251
|
+
e.skipped = now; saveLedger(ledger);
|
|
252
|
+
const file = join(e.from.path, ".symbiot", "ANSWERS.md");
|
|
253
|
+
try { if (existsSync(join(e.from.path, ".symbiot"))) writeFileSync(file, `${(existsSync(file) ? readFileSync(file, "utf8") : "# Answers\n").replace(/\s*$/, "")}\n\n### Handed over to ${e.to.lane}: ${clipWords(firstLine(e.text), 120)}\nThe user skipped it: don't hand it over again.\n_answered ${new Date(now).toISOString().slice(0, 10)}_\n`); } catch {}
|
|
254
|
+
return { ok: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---- Orca, kept pointing at the lanes ---------------------------------------------
|
|
258
|
+
// A handover can rename a lane's folder (CallForge AI → dailify), and the run that
|
|
259
|
+
// did it can't tell Orca (its CLI is outside what a run may do), so Orca keeps a
|
|
260
|
+
// path that's gone. After a handover comes back, and when the app starts, each repo
|
|
261
|
+
// Orca has at a path that's gone is added again where it is now: the scanned repo
|
|
262
|
+
// with the GitHub remote Orca kept for it, else the one with its name. Orca's CLI
|
|
263
|
+
// can't drop the old entry: it shows as missing there until you remove it.
|
|
264
|
+
const remoteKey = (url) => String(url || "").trim().replace(/^[a-z+]+:\/\//i, "").replace(/^[^@/]+@/, "").replace(/:(?!\d)/, "/").replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase();
|
|
265
|
+
const gitRemote = (path) => new Promise((res) => execFile("git", ["-C", path, "remote", "get-url", "origin"], { timeout: 5000, encoding: "utf8" }, (e, out) => res(e ? "" : out)));
|
|
266
|
+
const runOrca = (cli, args) => new Promise((res) => execFile(cli, args, { timeout: 20000, encoding: "utf8" }, (e, out) => res(e ? "" : out)));
|
|
267
|
+
// Orca's repos ({ path, displayName, gitRemoteIdentity }) whose folder is gone, each
|
|
268
|
+
// with where it is now: [{ from, to, name }]. Only a single match moves it.
|
|
269
|
+
async function orcaMoves(repos, map, { remoteOf = gitRemote, exists = existsSync } = {}) {
|
|
270
|
+
const known = new Set(repos.map((r) => r.path)), free = Object.values(map).filter((p) => !known.has(p) && exists(p)), remotes = new Map();
|
|
271
|
+
const remote = async (p) => { if (!remotes.has(p)) remotes.set(p, remoteKey(await remoteOf(p))); return remotes.get(p); };
|
|
272
|
+
const moves = [];
|
|
273
|
+
for (const r of repos) {
|
|
274
|
+
if (!r.path || exists(r.path)) continue;
|
|
275
|
+
const key = remoteKey((r.gitRemoteIdentity || {}).canonicalKey), hit = [];
|
|
276
|
+
if (key) for (const p of free) if ((await remote(p)) === key) hit.push(p);
|
|
277
|
+
if (!hit.length) { const names = [r.displayName, basename(r.path)].filter(Boolean).map((s) => s.toLowerCase()); hit.push(...free.filter((p) => names.includes(basename(p).toLowerCase()))); }
|
|
278
|
+
if (hit.length === 1 && !moves.some((m) => m.to === hit[0])) moves.push({ from: r.path, to: hit[0], name: r.displayName || basename(hit[0]) });
|
|
279
|
+
}
|
|
280
|
+
return moves;
|
|
281
|
+
}
|
|
282
|
+
// Add each moved repo again in Orca, where it is now. Nothing when Orca isn't
|
|
283
|
+
// installed or isn't running (its CLI can't reach it), and never from a sandbox
|
|
284
|
+
// (symbiot app --fresh): that Orca is your real one. { moves: [{ from, to, name, ok }] }
|
|
285
|
+
async function orcaRelink({ map, cli, orca = runOrca, remoteOf } = {}) {
|
|
286
|
+
if (process.env.SYMBIOT_SANDBOX) return { moves: [] };
|
|
287
|
+
cli = cli ?? findOrcaCli(); if (!cli) return { moves: [] };
|
|
288
|
+
let repos = []; try { repos = JSON.parse(await orca(cli, ["repo", "list", "--json"])).result.repos || []; } catch { return { moves: [] }; }
|
|
289
|
+
const moves = await orcaMoves(repos, map || repoPathMap(), { remoteOf });
|
|
290
|
+
for (const m of moves) m.ok = /"ok":\s*true/.test(await orca(cli, ["repo", "add", "--path", m.to, "--json"]));
|
|
291
|
+
return { moves };
|
|
292
|
+
}
|
|
166
293
|
// The Agents tab's list: newest first. Each shows its first line; full, all of a
|
|
167
294
|
// longer one, and result, all of what came back, open under it. What reads the
|
|
168
295
|
// same (one lane to the same lane, the same first line: a run that handed it
|
|
@@ -178,4 +305,4 @@ function lanesState() {
|
|
|
178
305
|
return { handoffs: out.slice(0, 30) };
|
|
179
306
|
}
|
|
180
307
|
|
|
181
|
-
export { LEDGER, MAX_CHAIN, loadLedger, laneOf, lastRunStart, dispatch, outcome, report, lanesTick, lanesState, aboutIt, partlyDone };
|
|
308
|
+
export { LEDGER, MAX_CHAIN, loadLedger, laneOf, lastRunStart, dispatch, outcome, report, lanesTick, lanesState, aboutIt, partlyDone, remoteKey, orcaMoves, orcaRelink, stuckHandovers, allowHandover, skipHandover, namedDirs, alike };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.54.
|
|
4
|
-
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice
|
|
3
|
+
"version": "0.54.4",
|
|
4
|
+
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"symbiot": "index.mjs"
|