@loompad/cli 1.2.2 → 1.2.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +10 -0
  3. package/dist/cli/index.js +59 -7
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/core/crew.d.ts +16 -1
  6. package/dist/core/crew.js +26 -2
  7. package/dist/core/crew.js.map +1 -1
  8. package/dist/core/orchestra.d.ts +36 -0
  9. package/dist/core/orchestra.js +154 -82
  10. package/dist/core/orchestra.js.map +1 -1
  11. package/dist/core/team-hub.d.ts +2 -1
  12. package/dist/core/team-hub.js +1 -1
  13. package/dist/core/team-hub.js.map +1 -1
  14. package/dist/core/team-leases.d.ts +7 -0
  15. package/dist/core/team-leases.js +19 -0
  16. package/dist/core/team-leases.js.map +1 -1
  17. package/dist/daemon/client.d.ts +12 -1
  18. package/dist/daemon/client.js +5 -2
  19. package/dist/daemon/client.js.map +1 -1
  20. package/dist/daemon/routes/brain.js +2 -2
  21. package/dist/daemon/routes/brain.js.map +1 -1
  22. package/dist/daemon/routes/orchestra.js +1 -0
  23. package/dist/daemon/routes/orchestra.js.map +1 -1
  24. package/dist/daemon/routes/project-team.js +17 -0
  25. package/dist/daemon/routes/project-team.js.map +1 -1
  26. package/dist/daemon/runner.d.ts +11 -0
  27. package/dist/daemon/runner.js +111 -5
  28. package/dist/daemon/runner.js.map +1 -1
  29. package/dist/daemon/runtime/briefings.js +12 -1
  30. package/dist/daemon/runtime/briefings.js.map +1 -1
  31. package/dist/daemon/runtime.d.ts +9 -2
  32. package/dist/daemon/runtime.js +8 -4
  33. package/dist/daemon/runtime.js.map +1 -1
  34. package/dist/daemon/team-coordinator.d.ts +27 -3
  35. package/dist/daemon/team-coordinator.js +54 -2
  36. package/dist/daemon/team-coordinator.js.map +1 -1
  37. package/dist/daemon/team.d.ts +42 -1
  38. package/dist/daemon/team.js +92 -1
  39. package/dist/daemon/team.js.map +1 -1
  40. package/dist/hub/supabase-client.d.ts +4 -2
  41. package/dist/hub/supabase-client.js +21 -12
  42. package/dist/hub/supabase-client.js.map +1 -1
  43. package/dist/web/app.css +12 -0
  44. package/dist/web/app.js +140 -28
  45. package/dist/web/app.js.map +3 -3
  46. package/docs/release-notes/v1.2.3.md +29 -0
  47. package/docs/release-notes/v1.2.4.md +13 -0
  48. package/docs/teams.md +31 -0
  49. package/package.json +1 -1
@@ -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
- this.host.gate(task.agent);
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
- let agent = this.live.get(key);
1748
- const freshSession = !agent;
1749
- if (!agent) {
1750
- const cfg = this.resolveAgent(task.agent) ?? { id: task.agent, kind: task.kind, role: "worker" };
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,125 @@ 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
+ // a later attempt is the orchestrator's follow-up, on top of what the first one did
1899
+ const followUp = task.attempts > 1 ? task.queued.shift() : undefined;
1900
+ const prompt = followUp
1901
+ ? `${followUp}\n\n(Your earlier work on this task is already in this branch. The task, for context:\n${task.prompt})`
1902
+ : task.prompt;
1903
+ this.host.append({ kind: "message", chat: task.chat, payload: { text: followUp ?? task.prompt, author: "orchestrator", orchestra: { runId: run.id, taskId: task.id } } });
1904
+ this.host.append({ kind: "status", chat: task.chat, payload: { state: "team_task", runId: run.id, taskId: task.id,
1905
+ text: `Sent to a teammate's machine (${task.kind}) — it runs there with their agent and comes back here to merge.` } });
1906
+ const res = await coord.runRemote(run, task, base, prompt);
1907
+ await this.gitLock.run(async () => {
1908
+ await git(["fetch", "-q", "origin", res.ref], task.dir);
1909
+ await git(["merge", "-q", "--ff-only", "FETCH_HEAD"], task.dir);
1910
+ });
1911
+ task.result = `${res.by ? `Done on ${res.by}'s machine. ` : ""}${res.summary || "(no report)"}`.slice(0, RESULT_CHARS);
1912
+ if (res.files.length)
1913
+ task.files = res.files;
1914
+ this.host.append({ kind: "message", agentId: task.agent, chat: task.chat,
1915
+ payload: { text: task.result, orchestra: { runId: run.id, taskId: task.id }, ...(res.costUsd !== undefined ? { costUsd: res.costUsd } : {}) } });
1916
+ }
1845
1917
  lastError = new Map();
1846
1918
  /** `${run}/${task}` → the question a worker stopped to ask mid-turn. */
1847
1919
  workerQuestion = new Map();