@loompad/cli 1.2.2 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/cli/index.js +33 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/core/crew.d.ts +16 -1
- package/dist/core/crew.js +26 -2
- package/dist/core/crew.js.map +1 -1
- package/dist/core/orchestra.d.ts +36 -0
- package/dist/core/orchestra.js +149 -82
- package/dist/core/orchestra.js.map +1 -1
- package/dist/core/team-hub.d.ts +2 -1
- package/dist/core/team-hub.js +1 -1
- package/dist/core/team-hub.js.map +1 -1
- package/dist/core/team-leases.d.ts +7 -0
- package/dist/core/team-leases.js +19 -0
- package/dist/core/team-leases.js.map +1 -1
- package/dist/daemon/client.d.ts +9 -1
- package/dist/daemon/client.js +2 -2
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/routes/brain.js +2 -2
- package/dist/daemon/routes/brain.js.map +1 -1
- package/dist/daemon/routes/orchestra.js +1 -0
- package/dist/daemon/routes/orchestra.js.map +1 -1
- package/dist/daemon/runner.d.ts +11 -0
- package/dist/daemon/runner.js +96 -3
- package/dist/daemon/runner.js.map +1 -1
- package/dist/daemon/runtime/briefings.js +12 -1
- package/dist/daemon/runtime/briefings.js.map +1 -1
- package/dist/daemon/runtime.d.ts +9 -2
- package/dist/daemon/runtime.js +8 -4
- package/dist/daemon/runtime.js.map +1 -1
- package/dist/daemon/team-coordinator.d.ts +27 -3
- package/dist/daemon/team-coordinator.js +54 -2
- package/dist/daemon/team-coordinator.js.map +1 -1
- package/dist/daemon/team.d.ts +9 -1
- package/dist/daemon/team.js +44 -1
- package/dist/daemon/team.js.map +1 -1
- package/dist/hub/supabase-client.d.ts +4 -2
- package/dist/hub/supabase-client.js +21 -12
- package/dist/hub/supabase-client.js.map +1 -1
- package/dist/web/app.css +4 -0
- package/dist/web/app.js +96 -27
- package/dist/web/app.js.map +3 -3
- package/docs/release-notes/v1.2.3.md +29 -0
- package/docs/teams.md +28 -0
- package/package.json +1 -1
package/dist/core/orchestra.js
CHANGED
|
@@ -61,6 +61,8 @@ export function orchestraWorktreeRoot(projectDir, projectId, override) {
|
|
|
61
61
|
}
|
|
62
62
|
/** Finished runs keep their worktrees this long, then they're removed (the branches stay). */
|
|
63
63
|
export const ORCHESTRA_KEEP_DAYS = 7;
|
|
64
|
+
/** A worker on a teammate's machine (team auto-orchestration). */
|
|
65
|
+
export const isRemoteWorker = (agent) => agent.startsWith("team:");
|
|
64
66
|
/**
|
|
65
67
|
* Pull the actions out of an orchestrator reply.
|
|
66
68
|
*
|
|
@@ -233,8 +235,9 @@ function normalizeAction(v) {
|
|
|
233
235
|
/** The orchestrator's standing instructions, sent with its first turn. */
|
|
234
236
|
export function orchestratorBriefing(opts) {
|
|
235
237
|
const roster = opts.workers
|
|
236
|
-
.map((w) => `- "${w.id}" — ${KIND_BLURBS[w.kind] ?? w.kind}${w.role && w.role !== w.id ? ` (role: ${w.role})` : ""}`)
|
|
238
|
+
.map((w) => `- "${w.id}" — ${KIND_BLURBS[w.kind] ?? w.kind}${isRemoteWorker(w.id) ? " — on a teammate's machine" : w.role && w.role !== w.id ? ` (role: ${w.role})` : ""}`)
|
|
237
239
|
.join("\n");
|
|
240
|
+
const team = opts.workers.some((w) => isRemoteWorker(w.id));
|
|
238
241
|
return [
|
|
239
242
|
`[Loom Orchestra] You are the ORCHESTRATOR for project "${opts.project}".`,
|
|
240
243
|
"Your job is to coordinate, not to implement. Break the goal into concrete, independently",
|
|
@@ -255,6 +258,14 @@ export function orchestratorBriefing(opts) {
|
|
|
255
258
|
"",
|
|
256
259
|
"Available workers:",
|
|
257
260
|
roster,
|
|
261
|
+
...(team
|
|
262
|
+
? [
|
|
263
|
+
"",
|
|
264
|
+
'Workers named "team:<agent>" run on a TEAMMATE\'S machine with their own agent, in parallel with yours.',
|
|
265
|
+
"Use them for self-contained tasks with clear touches (they start from the integration branch and",
|
|
266
|
+
"can't ask you questions mid-task); keep tightly coupled or exploratory work on local workers.",
|
|
267
|
+
]
|
|
268
|
+
: []),
|
|
258
269
|
"",
|
|
259
270
|
"End EVERY reply with exactly one fenced block tagged loom containing your actions:",
|
|
260
271
|
"```loom",
|
|
@@ -579,6 +590,11 @@ export class OrchestraEngine {
|
|
|
579
590
|
resolveAgent(name) {
|
|
580
591
|
const roster = this.host.roster();
|
|
581
592
|
const n = name.trim().toLowerCase();
|
|
593
|
+
// a teammate's agent (team auto-orchestration): runs on their machine, not this roster
|
|
594
|
+
if (isRemoteWorker(n)) {
|
|
595
|
+
const kind = n.slice(5).trim();
|
|
596
|
+
return kind ? { id: `team:${kind}`, kind, role: "teammate" } : null;
|
|
597
|
+
}
|
|
582
598
|
const byId = roster.find((a) => a.id.toLowerCase() === n);
|
|
583
599
|
if (byId)
|
|
584
600
|
return byId;
|
|
@@ -636,6 +652,16 @@ export class OrchestraEngine {
|
|
|
636
652
|
}
|
|
637
653
|
if (!workers.length)
|
|
638
654
|
throw new Error("an orchestra needs at least one worker agent");
|
|
655
|
+
// Team auto-orchestration: with teammates' shared runners online, their agents join the
|
|
656
|
+
// run as team:<kind> workers — the orchestrator splits the goal across both machines.
|
|
657
|
+
if (!opts.workers?.length && !opts.race && opts.team !== false) {
|
|
658
|
+
const kinds = await this.host.coordinator?.()?.remoteWorkers?.().catch(() => []) ?? [];
|
|
659
|
+
for (const k of kinds) {
|
|
660
|
+
const cfg = this.resolveAgent(`team:${k}`);
|
|
661
|
+
if (cfg && !workers.some((x) => x.id === cfg.id))
|
|
662
|
+
workers.push(cfg);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
639
665
|
if (opts.race && workers.length < 2)
|
|
640
666
|
throw new Error("a race needs at least two agents");
|
|
641
667
|
this.host.gate(orchCfg.id);
|
|
@@ -1730,7 +1756,8 @@ export class OrchestraEngine {
|
|
|
1730
1756
|
this.emit(run, "task_started", { taskId: task.id, agent: task.agent, title: task.title }, task.chat);
|
|
1731
1757
|
const key = `${run.id}/${task.id}`;
|
|
1732
1758
|
try {
|
|
1733
|
-
|
|
1759
|
+
if (!isRemoteWorker(task.agent))
|
|
1760
|
+
this.host.gate(task.agent);
|
|
1734
1761
|
if (!task.dir) {
|
|
1735
1762
|
const slug = task.id;
|
|
1736
1763
|
const branch = `${run.branch.replace(/\/main$/, "")}/${slug}`;
|
|
@@ -1744,88 +1771,14 @@ export class OrchestraEngine {
|
|
|
1744
1771
|
task.dir = dir;
|
|
1745
1772
|
this.save(run);
|
|
1746
1773
|
}
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
agent = this.host.makeAgent({ ...cfg, role: "worker" }, task.dir);
|
|
1752
|
-
this.live.set(key, agent);
|
|
1753
|
-
this.wire(run, agent, cfg.id, task.chat, key);
|
|
1754
|
-
await agent.start();
|
|
1755
|
-
}
|
|
1756
|
-
// The first attempt always sends the task itself — a follow-up the
|
|
1757
|
-
// orchestrator queued in the same breath waits its turn behind it.
|
|
1758
|
-
const first = task.attempts === 1;
|
|
1759
|
-
// A later attempt sends the orchestrator's next follow-up. With none
|
|
1760
|
-
// queued, this is a task picking back up after Loom restarted (resume,
|
|
1761
|
-
// or a goal moved here): say so, and give it the task again — its
|
|
1762
|
-
// session is new, its worktree still has everything it did.
|
|
1763
|
-
const followUp = first ? undefined : task.queued.shift();
|
|
1764
|
-
const text = first
|
|
1765
|
-
? task.prompt
|
|
1766
|
-
: (followUp ??
|
|
1767
|
-
`Loom was restarted while you were on this task (${task.title}). Everything you'd done is still in this worktree. ` +
|
|
1768
|
-
`Check where it stands, finish the task, and report back.\n\nThe task, again:\n${task.prompt}`);
|
|
1769
|
-
this.host.append({
|
|
1770
|
-
kind: "message",
|
|
1771
|
-
chat: task.chat,
|
|
1772
|
-
payload: { text, author: first ? "orchestrator" : "orchestrator", orchestra: { runId: run.id, taskId: task.id } },
|
|
1773
|
-
});
|
|
1774
|
-
this.turnText.set(key, "");
|
|
1775
|
-
const briefing = first || freshSession
|
|
1776
|
-
? workerBriefing({
|
|
1777
|
-
project: this.host.projectName,
|
|
1778
|
-
runGoal: run.goal,
|
|
1779
|
-
task,
|
|
1780
|
-
branch: task.branch,
|
|
1781
|
-
brain: await this.host.briefingFor(task.prompt, task.agent, task.touches ?? task.files ?? []),
|
|
1782
|
-
...(run.plan ? { planFile: `${planDir(run)}/${task.id}.md` } : {}),
|
|
1783
|
-
})
|
|
1784
|
-
: undefined;
|
|
1785
|
-
await agent.send({ text, ...(briefing ? { briefing } : {}) });
|
|
1786
|
-
if (task.status === "cancelled" || isTerminal(run.status))
|
|
1787
|
-
return;
|
|
1788
|
-
// paused mid-turn (pauseTask): not finished, nothing to integrate yet
|
|
1789
|
-
if (task.status === "pending") {
|
|
1790
|
-
this.schedule(run);
|
|
1791
|
-
return;
|
|
1792
|
-
}
|
|
1793
|
-
const reply = (this.turnText.get(key) ?? "").trim();
|
|
1794
|
-
this.turnText.delete(key); // kept on the task (task.result), not here, once the turn is read
|
|
1795
|
-
task.result = reply.length > RESULT_CHARS ? `${reply.slice(0, RESULT_CHARS)}\n… (truncated)` : reply || "(no report)";
|
|
1796
|
-
const turnError = this.lastError.get(key);
|
|
1797
|
-
this.lastError.delete(key);
|
|
1798
|
-
const asked = this.workerQuestion.get(key);
|
|
1799
|
-
this.workerQuestion.delete(key);
|
|
1800
|
-
if (asked) {
|
|
1801
|
-
// Its work so far stays in the worktree, unmerged; the orchestrator
|
|
1802
|
-
// answers with a `send` (the task resumes there) or asks the human.
|
|
1803
|
-
task.status = "needs_input";
|
|
1804
|
-
task.result = `The worker stopped to ask: ${asked}${reply ? `\n\nWhat it said before asking:\n${task.result}` : ""}`;
|
|
1805
|
-
task.finishedAt = Date.now();
|
|
1806
|
-
task.reported = false;
|
|
1807
|
-
this.save(run);
|
|
1808
|
-
this.emit(run, "task", { task: taskSummary(task) });
|
|
1809
|
-
this.emit(run, "task_finished", { taskId: task.id, status: task.status, files: task.files ?? [] }, task.chat);
|
|
1810
|
-
this.schedule(run);
|
|
1811
|
-
return;
|
|
1812
|
-
}
|
|
1813
|
-
if (turnError)
|
|
1814
|
-
task.error = turnError;
|
|
1815
|
-
// A turn that errored and said nothing died — it is not "done", and a
|
|
1816
|
-
// task depending on it must not start on top of it. (An error event
|
|
1817
|
-
// alongside a real report is a warning the orchestrator gets to read.)
|
|
1818
|
-
if (turnError && !reply) {
|
|
1819
|
-
task.status = "failed";
|
|
1820
|
-
}
|
|
1821
|
-
else if (task.queued.length) {
|
|
1822
|
-
// The orchestrator already queued a follow-up: keep going on the same
|
|
1823
|
-
// worktree before merging.
|
|
1824
|
-
task.status = "pending";
|
|
1825
|
-
}
|
|
1826
|
-
else {
|
|
1774
|
+
if (isRemoteWorker(task.agent)) {
|
|
1775
|
+
await this.runRemoteTask(run, task);
|
|
1776
|
+
if (task.status === "cancelled" || isTerminal(run.status))
|
|
1777
|
+
return;
|
|
1827
1778
|
await this.integrate(run, task);
|
|
1828
1779
|
}
|
|
1780
|
+
else if (!(await this.runLocalTurn(run, task, key)))
|
|
1781
|
+
return;
|
|
1829
1782
|
}
|
|
1830
1783
|
catch (err) {
|
|
1831
1784
|
if (task.status !== "cancelled") {
|
|
@@ -1842,6 +1795,120 @@ export class OrchestraEngine {
|
|
|
1842
1795
|
this.host.coordinator?.()?.onTaskDone?.(run, task);
|
|
1843
1796
|
this.schedule(run);
|
|
1844
1797
|
}
|
|
1798
|
+
/**
|
|
1799
|
+
* One turn of a local worker on its task, through to integration. False when the
|
|
1800
|
+
* task stopped short (cancelled, paused, or the worker asked a question and the
|
|
1801
|
+
* task already reported itself) — runTask then has nothing more to record.
|
|
1802
|
+
*/
|
|
1803
|
+
async runLocalTurn(run, task, key) {
|
|
1804
|
+
let agent = this.live.get(key);
|
|
1805
|
+
const freshSession = !agent;
|
|
1806
|
+
if (!agent) {
|
|
1807
|
+
const cfg = this.resolveAgent(task.agent) ?? { id: task.agent, kind: task.kind, role: "worker" };
|
|
1808
|
+
agent = this.host.makeAgent({ ...cfg, role: "worker" }, task.dir);
|
|
1809
|
+
this.live.set(key, agent);
|
|
1810
|
+
this.wire(run, agent, cfg.id, task.chat, key);
|
|
1811
|
+
await agent.start();
|
|
1812
|
+
}
|
|
1813
|
+
// The first attempt always sends the task itself — a follow-up the
|
|
1814
|
+
// orchestrator queued in the same breath waits its turn behind it.
|
|
1815
|
+
const first = task.attempts === 1;
|
|
1816
|
+
// A later attempt sends the orchestrator's next follow-up. With none
|
|
1817
|
+
// queued, this is a task picking back up after Loom restarted (resume,
|
|
1818
|
+
// or a goal moved here): say so, and give it the task again — its
|
|
1819
|
+
// session is new, its worktree still has everything it did.
|
|
1820
|
+
const followUp = first ? undefined : task.queued.shift();
|
|
1821
|
+
const text = first
|
|
1822
|
+
? task.prompt
|
|
1823
|
+
: (followUp ??
|
|
1824
|
+
`Loom was restarted while you were on this task (${task.title}). Everything you'd done is still in this worktree. ` +
|
|
1825
|
+
`Check where it stands, finish the task, and report back.\n\nThe task, again:\n${task.prompt}`);
|
|
1826
|
+
this.host.append({
|
|
1827
|
+
kind: "message",
|
|
1828
|
+
chat: task.chat,
|
|
1829
|
+
payload: { text, author: first ? "orchestrator" : "orchestrator", orchestra: { runId: run.id, taskId: task.id } },
|
|
1830
|
+
});
|
|
1831
|
+
this.turnText.set(key, "");
|
|
1832
|
+
const briefing = first || freshSession
|
|
1833
|
+
? workerBriefing({
|
|
1834
|
+
project: this.host.projectName,
|
|
1835
|
+
runGoal: run.goal,
|
|
1836
|
+
task,
|
|
1837
|
+
branch: task.branch,
|
|
1838
|
+
brain: await this.host.briefingFor(task.prompt, task.agent, task.touches ?? task.files ?? []),
|
|
1839
|
+
...(run.plan ? { planFile: `${planDir(run)}/${task.id}.md` } : {}),
|
|
1840
|
+
})
|
|
1841
|
+
: undefined;
|
|
1842
|
+
await agent.send({ text, ...(briefing ? { briefing } : {}) });
|
|
1843
|
+
if (task.status === "cancelled" || isTerminal(run.status))
|
|
1844
|
+
return false;
|
|
1845
|
+
// paused mid-turn (pauseTask): not finished, nothing to integrate yet
|
|
1846
|
+
if (task.status === "pending") {
|
|
1847
|
+
this.schedule(run);
|
|
1848
|
+
return false;
|
|
1849
|
+
}
|
|
1850
|
+
const reply = (this.turnText.get(key) ?? "").trim();
|
|
1851
|
+
this.turnText.delete(key); // kept on the task (task.result), not here, once the turn is read
|
|
1852
|
+
task.result = reply.length > RESULT_CHARS ? `${reply.slice(0, RESULT_CHARS)}\n… (truncated)` : reply || "(no report)";
|
|
1853
|
+
const turnError = this.lastError.get(key);
|
|
1854
|
+
this.lastError.delete(key);
|
|
1855
|
+
const asked = this.workerQuestion.get(key);
|
|
1856
|
+
this.workerQuestion.delete(key);
|
|
1857
|
+
if (asked) {
|
|
1858
|
+
// Its work so far stays in the worktree, unmerged; the orchestrator
|
|
1859
|
+
// answers with a `send` (the task resumes there) or asks the human.
|
|
1860
|
+
task.status = "needs_input";
|
|
1861
|
+
task.result = `The worker stopped to ask: ${asked}${reply ? `\n\nWhat it said before asking:\n${task.result}` : ""}`;
|
|
1862
|
+
task.finishedAt = Date.now();
|
|
1863
|
+
task.reported = false;
|
|
1864
|
+
this.save(run);
|
|
1865
|
+
this.emit(run, "task", { task: taskSummary(task) });
|
|
1866
|
+
this.emit(run, "task_finished", { taskId: task.id, status: task.status, files: task.files ?? [] }, task.chat);
|
|
1867
|
+
this.schedule(run);
|
|
1868
|
+
return false;
|
|
1869
|
+
}
|
|
1870
|
+
if (turnError)
|
|
1871
|
+
task.error = turnError;
|
|
1872
|
+
// A turn that errored and said nothing died — it is not "done", and a
|
|
1873
|
+
// task depending on it must not start on top of it. (An error event
|
|
1874
|
+
// alongside a real report is a warning the orchestrator gets to read.)
|
|
1875
|
+
if (turnError && !reply) {
|
|
1876
|
+
task.status = "failed";
|
|
1877
|
+
}
|
|
1878
|
+
else if (task.queued.length) {
|
|
1879
|
+
// The orchestrator already queued a follow-up: keep going on the same
|
|
1880
|
+
// worktree before merging.
|
|
1881
|
+
task.status = "pending";
|
|
1882
|
+
}
|
|
1883
|
+
else {
|
|
1884
|
+
await this.integrate(run, task);
|
|
1885
|
+
}
|
|
1886
|
+
return true;
|
|
1887
|
+
}
|
|
1888
|
+
/**
|
|
1889
|
+
* A team:<kind> task: a teammate's machine runs it with its own agent from this
|
|
1890
|
+
* task's starting commit, pushes its work to a ref on origin, and the commits come
|
|
1891
|
+
* back onto the task's branch here — then it integrates like any other task.
|
|
1892
|
+
*/
|
|
1893
|
+
async runRemoteTask(run, task) {
|
|
1894
|
+
const coord = this.host.coordinator?.();
|
|
1895
|
+
if (!coord?.runRemote)
|
|
1896
|
+
throw new Error("no teammate's machine can take this task (Loom Teams isn't set up here)");
|
|
1897
|
+
const base = (await git(["rev-parse", "HEAD"], task.dir)).trim();
|
|
1898
|
+
this.host.append({ kind: "message", chat: task.chat, payload: { text: task.prompt, author: "orchestrator", orchestra: { runId: run.id, taskId: task.id } } });
|
|
1899
|
+
this.host.append({ kind: "status", chat: task.chat, payload: { state: "team_task", runId: run.id, taskId: task.id,
|
|
1900
|
+
text: `Sent to a teammate's machine (${task.kind}) — it runs there with their agent and comes back here to merge.` } });
|
|
1901
|
+
const res = await coord.runRemote(run, task, base);
|
|
1902
|
+
await this.gitLock.run(async () => {
|
|
1903
|
+
await git(["fetch", "-q", "origin", res.ref], task.dir);
|
|
1904
|
+
await git(["merge", "-q", "--ff-only", "FETCH_HEAD"], task.dir);
|
|
1905
|
+
});
|
|
1906
|
+
task.result = `${res.by ? `Done on ${res.by}'s machine. ` : ""}${res.summary || "(no report)"}`.slice(0, RESULT_CHARS);
|
|
1907
|
+
if (res.files.length)
|
|
1908
|
+
task.files = res.files;
|
|
1909
|
+
this.host.append({ kind: "message", agentId: task.agent, chat: task.chat,
|
|
1910
|
+
payload: { text: task.result, orchestra: { runId: run.id, taskId: task.id }, ...(res.costUsd !== undefined ? { costUsd: res.costUsd } : {}) } });
|
|
1911
|
+
}
|
|
1845
1912
|
lastError = new Map();
|
|
1846
1913
|
/** `${run}/${task}` → the question a worker stopped to ask mid-turn. */
|
|
1847
1914
|
workerQuestion = new Map();
|