@loompad/cli 1.1.2 → 1.2.1

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 (134) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +31 -10
  3. package/dist/adapters/antigravity-cli.d.ts +2 -0
  4. package/dist/adapters/antigravity-cli.js +27 -4
  5. package/dist/adapters/antigravity-cli.js.map +1 -1
  6. package/dist/adapters/model.js +1 -1
  7. package/dist/adapters/model.js.map +1 -1
  8. package/dist/adapters/opencode.d.ts +4 -0
  9. package/dist/adapters/opencode.js +18 -0
  10. package/dist/adapters/opencode.js.map +1 -1
  11. package/dist/cli/doctor.d.ts +38 -1
  12. package/dist/cli/doctor.js +157 -3
  13. package/dist/cli/doctor.js.map +1 -1
  14. package/dist/cli/index.js +357 -20
  15. package/dist/cli/index.js.map +1 -1
  16. package/dist/cli/ui.js +11 -7
  17. package/dist/cli/ui.js.map +1 -1
  18. package/dist/core/auto-policy.d.ts +46 -0
  19. package/dist/core/auto-policy.js +181 -0
  20. package/dist/core/auto-policy.js.map +1 -0
  21. package/dist/core/council.d.ts +55 -0
  22. package/dist/core/council.js +73 -0
  23. package/dist/core/council.js.map +1 -0
  24. package/dist/core/crew-protocol.js +4 -1
  25. package/dist/core/crew-protocol.js.map +1 -1
  26. package/dist/core/crew.d.ts +13 -1
  27. package/dist/core/crew.js +40 -6
  28. package/dist/core/crew.js.map +1 -1
  29. package/dist/core/digest.d.ts +2 -0
  30. package/dist/core/digest.js +9 -0
  31. package/dist/core/digest.js.map +1 -1
  32. package/dist/core/loops.js +22 -2
  33. package/dist/core/loops.js.map +1 -1
  34. package/dist/core/orchestra.d.ts +16 -0
  35. package/dist/core/orchestra.js +45 -2
  36. package/dist/core/orchestra.js.map +1 -1
  37. package/dist/core/permissions.js +2 -2
  38. package/dist/core/permissions.js.map +1 -1
  39. package/dist/core/prices.d.ts +66 -0
  40. package/dist/core/prices.js +109 -0
  41. package/dist/core/prices.js.map +1 -0
  42. package/dist/core/proof.d.ts +13 -1
  43. package/dist/core/proof.js +89 -17
  44. package/dist/core/proof.js.map +1 -1
  45. package/dist/core/providers.d.ts +13 -0
  46. package/dist/core/providers.js +17 -0
  47. package/dist/core/providers.js.map +1 -1
  48. package/dist/core/replay.js +14 -0
  49. package/dist/core/replay.js.map +1 -1
  50. package/dist/core/review.d.ts +60 -0
  51. package/dist/core/review.js +121 -0
  52. package/dist/core/review.js.map +1 -0
  53. package/dist/core/routes.js +14 -0
  54. package/dist/core/routes.js.map +1 -1
  55. package/dist/core/setup.d.ts +27 -0
  56. package/dist/core/setup.js +39 -7
  57. package/dist/core/setup.js.map +1 -1
  58. package/dist/core/shield.js +7 -3
  59. package/dist/core/shield.js.map +1 -1
  60. package/dist/core/shift.d.ts +2 -0
  61. package/dist/core/shift.js +14 -2
  62. package/dist/core/shift.js.map +1 -1
  63. package/dist/core/turn-wait.d.ts +20 -0
  64. package/dist/core/turn-wait.js +39 -0
  65. package/dist/core/turn-wait.js.map +1 -0
  66. package/dist/core/usage.d.ts +7 -1
  67. package/dist/core/usage.js +11 -3
  68. package/dist/core/usage.js.map +1 -1
  69. package/dist/daemon/client.d.ts +60 -1
  70. package/dist/daemon/client.js +40 -1
  71. package/dist/daemon/client.js.map +1 -1
  72. package/dist/daemon/council.d.ts +98 -0
  73. package/dist/daemon/council.js +254 -0
  74. package/dist/daemon/council.js.map +1 -0
  75. package/dist/daemon/proof.d.ts +6 -0
  76. package/dist/daemon/proof.js +32 -9
  77. package/dist/daemon/proof.js.map +1 -1
  78. package/dist/daemon/push.js +7 -0
  79. package/dist/daemon/push.js.map +1 -1
  80. package/dist/daemon/review.d.ts +95 -0
  81. package/dist/daemon/review.js +154 -0
  82. package/dist/daemon/review.js.map +1 -0
  83. package/dist/daemon/routes/council.d.ts +4 -0
  84. package/dist/daemon/routes/council.js +29 -0
  85. package/dist/daemon/routes/council.js.map +1 -0
  86. package/dist/daemon/routes/crews.js +2 -0
  87. package/dist/daemon/routes/crews.js.map +1 -1
  88. package/dist/daemon/routes/messages.js +8 -2
  89. package/dist/daemon/routes/messages.js.map +1 -1
  90. package/dist/daemon/routes/review.d.ts +4 -0
  91. package/dist/daemon/routes/review.js +40 -0
  92. package/dist/daemon/routes/review.js.map +1 -0
  93. package/dist/daemon/routes/routing.js +2 -1
  94. package/dist/daemon/routes/routing.js.map +1 -1
  95. package/dist/daemon/routes/setup.d.ts +2 -1
  96. package/dist/daemon/routes/setup.js +22 -6
  97. package/dist/daemon/routes/setup.js.map +1 -1
  98. package/dist/daemon/runtime/accounting.d.ts +6 -0
  99. package/dist/daemon/runtime/accounting.js +37 -1
  100. package/dist/daemon/runtime/accounting.js.map +1 -1
  101. package/dist/daemon/runtime/briefings.d.ts +8 -3
  102. package/dist/daemon/runtime/briefings.js +26 -14
  103. package/dist/daemon/runtime/briefings.js.map +1 -1
  104. package/dist/daemon/runtime/queue.d.ts +7 -0
  105. package/dist/daemon/runtime/queue.js +13 -1
  106. package/dist/daemon/runtime/queue.js.map +1 -1
  107. package/dist/daemon/runtime/relay.d.ts +2 -0
  108. package/dist/daemon/runtime/relay.js +7 -0
  109. package/dist/daemon/runtime/relay.js.map +1 -1
  110. package/dist/daemon/runtime/turns.d.ts +2 -0
  111. package/dist/daemon/runtime/turns.js +1 -0
  112. package/dist/daemon/runtime/turns.js.map +1 -1
  113. package/dist/daemon/runtime-support.js +1 -1
  114. package/dist/daemon/runtime-support.js.map +1 -1
  115. package/dist/daemon/runtime.d.ts +65 -3
  116. package/dist/daemon/runtime.js +238 -5
  117. package/dist/daemon/runtime.js.map +1 -1
  118. package/dist/daemon/server.js +5 -1
  119. package/dist/daemon/server.js.map +1 -1
  120. package/dist/daemon/shift.js +1 -1
  121. package/dist/daemon/shift.js.map +1 -1
  122. package/dist/providers/agent.js +6 -0
  123. package/dist/providers/agent.js.map +1 -1
  124. package/dist/providers/claude/adapter.d.ts +3 -0
  125. package/dist/providers/claude/adapter.js +20 -1
  126. package/dist/providers/claude/adapter.js.map +1 -1
  127. package/dist/types.d.ts +31 -1
  128. package/dist/types.js.map +1 -1
  129. package/dist/web/app.css +51 -0
  130. package/dist/web/app.js +172 -18
  131. package/dist/web/app.js.map +3 -3
  132. package/docs/release-notes/v1.2.0.md +48 -0
  133. package/docs/release-notes/v1.2.1.md +40 -0
  134. package/package.json +1 -1
@@ -7,6 +7,7 @@ import { NativeUsage } from "./runtime/native-usage.js";
7
7
  import { OrchestraEngine, type OrchestraCoordinator } from "../core/orchestra.js";
8
8
  import { CrewEngine } from "../core/crew.js";
9
9
  import { type Loop } from "../core/loops.js";
10
+ import { type AutoPolicy } from "../core/auto-policy.js";
10
11
  import { QuotaRelay } from "./runtime/relay.js";
11
12
  import { ProofRunner } from "./proof.js";
12
13
  import { NightShift } from "./shift.js";
@@ -29,6 +30,9 @@ import { ServerFrame, TeamBrainHook } from './runtime-support.js';
29
30
  import { type TurnOptions, type TurnResult } from './runtime/turns.js';
30
31
  import { ContinuityEngine } from "../core/continuity/engine.js";
31
32
  import { HarnessMonitor } from "../core/continuity/capabilities.js";
33
+ import { type ReviewTarget } from "../core/review.js";
34
+ import { ReviewRunner } from "./review.js";
35
+ import { CouncilRunner } from "./council.js";
32
36
  import { type LiveFrame } from "../providers/live.js";
33
37
  import { type AgentCheck } from "../adapters/base.js";
34
38
  import type { LiveText } from "../types.js";
@@ -58,8 +62,12 @@ export declare class ProjectRuntime {
58
62
  readonly relay: QuotaRelay;
59
63
  /** Proof: checks an agent's "done" against what ran and a clean re-run of the tests. */
60
64
  readonly proofs: ProofRunner;
65
+ /** Loom's Auto policy, registered by project id for this project's agents (core/auto-policy.ts). */
66
+ private readonly unregisterAutoPolicy;
61
67
  /** Night Shift: a line of tasks run while you're away, then one report (daemon/shift.ts). */
62
68
  readonly shifts: NightShift;
69
+ readonly reviews: ReviewRunner;
70
+ readonly councils: CouncilRunner;
63
71
  /** Adapter kinds installed on this machine, probed once at open. */
64
72
  private installedKinds;
65
73
  /** Memory as units — see core/brain.ts. Reads and writes through `log`. */
@@ -707,8 +715,8 @@ export declare class ProjectRuntime {
707
715
  compactAgent(agentId: string, chat?: string): Promise<void>;
708
716
  /**
709
717
  * A plan turn on a provider agent runs in the agent's own plan mode, which
710
- * changes nothing; Loom keeps the plan it proposes under plans/, as its plan
711
- * mode always has.
718
+ * changes nothing; Loom keeps the plan it proposes under .loom/plans/ — its
719
+ * own folder, not a new untracked one in your repo (#279).
712
720
  */
713
721
  private saveProposedPlan;
714
722
  onServerEvent(cb: (f: ServerFrame) => void): () => void;
@@ -739,12 +747,38 @@ export declare class ProjectRuntime {
739
747
  * project, a dropped table. Said at once, in its chat and as a push; with
740
748
  * shield.auto "stop" (the default on a night shift) the turn is stopped too.
741
749
  */
750
+ /** Agents whose last turn failed on sign-in, and the message — until a turn succeeds (#251). */
751
+ private readonly signedOutAgents;
752
+ private noteSignIn;
753
+ /**
754
+ * The size of an agent's native session in a chat — the context its last
755
+ * turn sent — and how long ago that was; a resumed session past the cache's
756
+ * life is re-read in full (#280).
757
+ */
758
+ sessionWeight(agentId: string, chat?: string): {
759
+ tokens: number;
760
+ ageMs: number;
761
+ model?: string;
762
+ cold: boolean;
763
+ estUsd?: number;
764
+ } | null;
765
+ /** The agents a real turn found signed out, with what the CLI said (for `loom doctor`). */
766
+ learnedSignOuts(): Array<{
767
+ agentId: string;
768
+ kind: string;
769
+ message: string;
770
+ }>;
771
+ /** What a restart would forget: each agent's latest sign-in failure that no successful turn has cleared since. */
772
+ private rehydrateSignIns;
773
+ /** What status says about an agent's sign-in: a real failure beats the CLI's own (cached) check. */
774
+ private signInState;
742
775
  private shield;
743
776
  /** The loop card's buttons: stop the turn, nudge the agent, or hand the task to another agent. */
744
777
  actOnLoop(chat: string, agentId: string, action: "stop" | "nudge" | "handoff", to?: string, loop?: Loop): Promise<{
745
778
  done: string;
746
779
  to?: string;
747
780
  }>;
781
+ private actOnLoopNow;
748
782
  private lastLoop;
749
783
  /**
750
784
  * Define or replace a named route.
@@ -814,7 +848,7 @@ export declare class ProjectRuntime {
814
848
  * each child is a real CLI process with its own model calls. The cap is per
815
849
  * project rather than global so one busy project can't starve the others.
816
850
  */
817
- private static readonly MAX_CONCURRENT_SUBTASKS;
851
+ static readonly MAX_CONCURRENT_SUBTASKS = 4;
818
852
  /** In-flight subtasks, by the id minted when they started. */
819
853
  private subtasks;
820
854
  /** Subtasks currently running, for the status payload and the cap. */
@@ -849,10 +883,34 @@ export declare class ProjectRuntime {
849
883
  agentId: string;
850
884
  task: string;
851
885
  chat?: string;
886
+ /** Investigate and report, change nothing: the agent's own plan mode where it has one, and said in the brief either way (a review). */
887
+ readOnly?: boolean;
888
+ /** Told once the child's turn has ended, however it ended. */
889
+ onSettled?: (outcome: {
890
+ ok: boolean;
891
+ message?: string;
892
+ }) => void;
852
893
  }): Promise<{
853
894
  id: string;
854
895
  agentId: string;
855
896
  }>;
897
+ /**
898
+ * `loom review`: these agents read the same thing at once, read-only, off
899
+ * the baton (daemon/review.ts). The target is a file, free text, or —
900
+ * by default — the uncommitted changes.
901
+ */
902
+ startReview(input: {
903
+ agents: string[];
904
+ file?: string;
905
+ text?: string;
906
+ focus?: string;
907
+ chat?: string;
908
+ }): Promise<import("./review.js").ReviewState>;
909
+ /** What a review reads: a file, free text, or the uncommitted changes. Throws when there's nothing to read. */
910
+ reviewTarget(input: {
911
+ file?: string;
912
+ text?: string;
913
+ }): ReviewTarget;
856
914
  private subtaskBriefing;
857
915
  private defaultAdapterId;
858
916
  private consumePendingBriefing;
@@ -907,6 +965,8 @@ export declare class ProjectRuntime {
907
965
  abortRoute(): Promise<RouteState>;
908
966
  routeState(): RouteState | null;
909
967
  status(): Promise<ProjectStatus>;
968
+ /** What's in flight besides an agent's own turn — an orchestra planning, a crew, a council — so "idle" isn't said of a busy project (#294). */
969
+ inFlight(): string[];
910
970
  /**
911
971
  * What every agent in this project is doing right now — for the fleet view.
912
972
  *
@@ -931,6 +991,8 @@ export declare class ProjectRuntime {
931
991
  * at the ceiling. Plan mode can't be known when an agent is built, so
932
992
  * `bypassRequiresPlan` treats it as off — the stricter reading.
933
993
  */
994
+ /** Loom's Auto policy for this project, read live by agents (core/auto-policy.ts). */
995
+ autoPolicy(): AutoPolicy;
934
996
  private policyOptions;
935
997
  /** Phase 5: this project is a runner's workspace for one goal (D70). */
936
998
  runnerMode: boolean;
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync, execSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createAgent, isWithdrawnKind, knownAgentKinds, tierForKind } from "../adapters/index.js";
@@ -20,6 +20,11 @@ import { OrchestraEngine } from "../core/orchestra.js";
20
20
  import { CrewEngine } from "../core/crew.js";
21
21
  import { LoopWatch, nudgeText } from "../core/loops.js";
22
22
  import { commandOf, dangerOf } from "../core/shield.js";
23
+ import { registerAutoPolicy } from "../core/auto-policy.js";
24
+ import { detectTestCommand } from "../core/proof.js";
25
+ import { knownAuth, signInCommand } from "../core/setup.js";
26
+ /** What a CLI says when it isn't signed in. */
27
+ const SIGNED_OUT = /\b(?:not signed in|not logged in|please (?:run )?\S*\s*\/?login|run `?[\w-]+ (?:auth )?login|sign in (?:first|again)|authentication (?:required|failed)|unauthori[sz]ed|invalid (?:api key|credentials)|401\b)/i;
23
28
  import { QuotaRelay } from "./runtime/relay.js";
24
29
  import { ProofRunner } from "./proof.js";
25
30
  import { NightShift } from "./shift.js";
@@ -50,6 +55,10 @@ import { ContinuityEngine } from "../core/continuity/engine.js";
50
55
  import { ContinuityError } from "../core/continuity/contracts.js";
51
56
  import { HarnessMonitor, isNativeKind } from "../core/continuity/capabilities.js";
52
57
  import { ProviderAgent } from "../providers/agent.js";
58
+ import { READ_ONLY_RULE } from "../core/review.js";
59
+ import { ReviewRunner } from "./review.js";
60
+ import { CouncilRunner } from "./council.js";
61
+ import { coldResume, priceBook } from "../core/prices.js";
53
62
  import { LiveDeltaThrottle } from "../providers/live.js";
54
63
  import { AdapterBase } from "../adapters/base.js";
55
64
  export { BudgetExceededError, CLOCK_TICK_MS, LOOM_ASK_TIMEOUT_MESSAGE, LOOM_ASK_TIMEOUT_MS, LoomAskTimeoutError, QuarantinedError, activityLine, planModeBriefing, relativeToProject, withLoomAskTimeout } from './runtime-support.js';
@@ -112,8 +121,12 @@ export class ProjectRuntime {
112
121
  relay;
113
122
  /** Proof: checks an agent's "done" against what ran and a clean re-run of the tests. */
114
123
  proofs;
124
+ /** Loom's Auto policy, registered by project id for this project's agents (core/auto-policy.ts). */
125
+ unregisterAutoPolicy;
115
126
  /** Night Shift: a line of tasks run while you're away, then one report (daemon/shift.ts). */
116
127
  shifts;
128
+ reviews;
129
+ councils;
117
130
  /** Adapter kinds installed on this machine, probed once at open. */
118
131
  installedKinds = [];
119
132
  /** Memory as units — see core/brain.ts. Reads and writes through `log`. */
@@ -292,6 +305,7 @@ export class ProjectRuntime {
292
305
  projectId: info.id,
293
306
  projectName: info.name,
294
307
  projectDir: info.dir,
308
+ worktreeDir: () => this.config.orchestra?.worktreeDir,
295
309
  roster: () => this.config.agents.filter((a) => a.enabled !== false && tierForKind(a.kind) === "adapter" && !isWithdrawnKind(a.kind)),
296
310
  installedKinds: () => this.installedKinds,
297
311
  makeAgent: (cfg, dir) => {
@@ -343,6 +357,7 @@ export class ProjectRuntime {
343
357
  config: () => this.config,
344
358
  busy: (id) => { const a = this.agents.get(id); return Boolean(a && isAdapter(a) && a.busy()) || this.turns.busySince.has(id); },
345
359
  turnChat: (id) => this.turns.turnChat.get(id),
360
+ inSubtask: (id) => [...this.subtasks.values()].some((t) => t.agentId === id),
346
361
  available: async (id) => { const a = this.agents.get(id); return Boolean(a && isAdapter(a) && (await a.available())); },
347
362
  interrupt: (chat) => this.interrupt({ chat, source: "route" }),
348
363
  handoff: (to) => this.handoff(to, { source: "route" }),
@@ -357,6 +372,7 @@ export class ProjectRuntime {
357
372
  append: (e) => (this.closed ? { ...e, id: -1, ts: Date.now() } : this.log.append(e)),
358
373
  warn: (message, detail) => logbook.warn("relay", message, String(detail), this.info.id),
359
374
  });
375
+ this.unregisterAutoPolicy = registerAutoPolicy(info.id, () => this.autoPolicy());
360
376
  this.proofs = new ProofRunner({
361
377
  projectId: info.id,
362
378
  dir: info.dir,
@@ -384,6 +400,67 @@ export class ProjectRuntime {
384
400
  append: (e) => (this.closed ? { ...e, id: -1, ts: Date.now() } : this.log.append(e)),
385
401
  warn: (message, detail) => logbook.warn("shift", message, String(detail), this.info.id),
386
402
  });
403
+ this.reviews = new ReviewRunner({
404
+ hasAgent: (id) => this.config.agents.some((a) => a.id === id),
405
+ canReview: (id) => {
406
+ const cfg = this.config.agents.find((a) => a.id === id);
407
+ return Boolean(cfg && cfg.enabled !== false && this.agents.has(id) && isAdapter(this.agent(id)));
408
+ },
409
+ holder: () => this.baton.holder() ?? undefined,
410
+ createChat: (title) => this.createChat(title),
411
+ spawn: (parent, opts) => this.spawnSubAgent(parent, opts),
412
+ list: (opts) => this.log.list(opts),
413
+ append: (e) => (this.closed ? { ...e, id: -1, ts: Date.now() } : this.log.append(e)),
414
+ // an accepted finding is a decision like `loom decision`: the event, and the memory every briefing carries
415
+ decide: (text) => {
416
+ if (this.closed)
417
+ return;
418
+ const event = this.log.append({ kind: "decision", payload: { text, source: "review" } });
419
+ this.brain.add({ kind: "decision", text, provenance: { agentId: "user", eventId: event.id, ts: event.ts } });
420
+ },
421
+ maxReviewers: ProjectRuntime.MAX_CONCURRENT_SUBTASKS,
422
+ reviewerTimeoutMs: () => Math.max(1, Number(this.config.review?.timeoutMin) || 15) * 60_000,
423
+ stopReviewer: async (agentId) => {
424
+ const a = this.agents.get(agentId);
425
+ if (a && isAdapter(a) && a.busy())
426
+ await a.interrupt();
427
+ },
428
+ });
429
+ this.councils = new CouncilRunner({
430
+ hasAgent: (id) => this.config.agents.some((a) => a.id === id),
431
+ canWrite: (id) => {
432
+ const cfg = this.config.agents.find((a) => a.id === id);
433
+ if (!cfg || cfg.enabled === false || !this.agents.has(id) || !isAdapter(this.agent(id)))
434
+ return false;
435
+ return cfg.kind !== "model" || Boolean(cfg.options?.write); // a model agent without write only answers
436
+ },
437
+ createChat: (title) => this.createChat(title),
438
+ holder: () => this.baton.holder() ?? undefined,
439
+ handoff: (to) => this.handoff(to, { source: "route" }),
440
+ send: (agentId, text, opts) => this.sendMessage(text, agentId, { chat: opts.chat, plan: opts.plan, source: "route" }),
441
+ interrupt: (chat) => this.interrupt({ chat, source: "route" }),
442
+ checkReviewers: (ids) => this.reviews.check(ids),
443
+ startReview: (input) => this.reviews.start(input),
444
+ reviewStatus: (id) => this.reviews.status(id),
445
+ diffTarget: () => this.reviewTarget({}),
446
+ decide: (id, decision, note) => void this.reviews.decide(id, decision, note),
447
+ // the turn may already be being checked (Proof checks a reply that claims something by itself): use that one
448
+ prove: async (chat) => {
449
+ const t = this.proofs.turn(chat);
450
+ if (!t)
451
+ return null;
452
+ for (let i = 0; i < 1200 && this.proofs.busy; i++)
453
+ await new Promise((r) => setTimeout(r, 250));
454
+ const had = this.log.list({ kinds: ["proof"], chat }).reverse().find((e) => e.payload.turn?.done === t.done.id);
455
+ if (had)
456
+ return { verdict: String(had.payload.verdict), headline: String(had.payload.headline) };
457
+ return this.proofs.prove(chat);
458
+ },
459
+ list: (opts) => this.log.list(opts),
460
+ append: (e) => (this.closed ? { ...e, id: -1, ts: Date.now() } : this.log.append(e)),
461
+ lastId: () => this.log.list({ limit: 1 }).at(-1)?.id ?? 0,
462
+ warn: (message, detail) => logbook.warn("council", message, String(detail), this.info.id),
463
+ });
387
464
  this.crews = new CrewEngine({
388
465
  projectId: info.id,
389
466
  projectName: info.name,
@@ -484,6 +561,10 @@ export class ProjectRuntime {
484
561
  }
485
562
  rt.configMtime = configMtimeOf(info.dir);
486
563
  rt.rehydrateCosts();
564
+ rt.rehydrateSignIns();
565
+ // finished orchestra runs older than a week give their disk back (#295)
566
+ setTimeout(() => void (rt.closed ? Promise.resolve([]) : rt.orchestra.sweep()).then((ids) => { if (ids.length)
567
+ logbook.info("orchestra", `removed the worktrees of ${ids.length} finished run(s)`, ids.join(", "), rt.info.id); }).catch(() => { }), 30_000).unref?.();
487
568
  // Pull each connected ADE's native memory into the shared brain on open.
488
569
  try {
489
570
  rt.importMemories();
@@ -1976,6 +2057,7 @@ export class ProjectRuntime {
1976
2057
  afterAgentEvent(event) {
1977
2058
  this.trackCost(event);
1978
2059
  this.shield(event);
2060
+ this.noteSignIn(event);
1979
2061
  const loop = this.loops.observe(event);
1980
2062
  if (loop)
1981
2063
  void this.onLoop(loop).catch((err) => logbook.warn("loops", "couldn't act on a loop", String(err), this.info.id));
@@ -2309,8 +2391,8 @@ export class ProjectRuntime {
2309
2391
  }
2310
2392
  /**
2311
2393
  * A plan turn on a provider agent runs in the agent's own plan mode, which
2312
- * changes nothing; Loom keeps the plan it proposes under plans/, as its plan
2313
- * mode always has.
2394
+ * changes nothing; Loom keeps the plan it proposes under .loom/plans/ — its
2395
+ * own folder, not a new untracked one in your repo (#279).
2314
2396
  */
2315
2397
  saveProposedPlan(agentId, chat, markdown) {
2316
2398
  if (!markdown.trim())
@@ -2319,7 +2401,7 @@ export class ProjectRuntime {
2319
2401
  const heading = /^#+\s*(.+)$/m.exec(markdown)?.[1] ?? markdown.split("\n")[0] ?? "plan";
2320
2402
  const slug = heading.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "plan";
2321
2403
  const day = new Date().toISOString().slice(0, 10);
2322
- const dir = path.join(this.agentDir(agentId), "plans");
2404
+ const dir = path.join(projectLoomDir(this.agentDir(agentId)), "plans");
2323
2405
  fs.mkdirSync(dir, { recursive: true });
2324
2406
  let file = path.join(dir, `${day}-${slug}.md`);
2325
2407
  for (let n = 2; fs.existsSync(file); n++)
@@ -2420,6 +2502,52 @@ export class ProjectRuntime {
2420
2502
  * project, a dropped table. Said at once, in its chat and as a push; with
2421
2503
  * shield.auto "stop" (the default on a night shift) the turn is stopped too.
2422
2504
  */
2505
+ /** Agents whose last turn failed on sign-in, and the message — until a turn succeeds (#251). */
2506
+ signedOutAgents = new Map();
2507
+ noteSignIn(event) {
2508
+ if (!event.agentId)
2509
+ return;
2510
+ if (event.kind === "run_complete" && !event.payload.error) {
2511
+ this.signedOutAgents.delete(event.agentId);
2512
+ return;
2513
+ }
2514
+ const message = event.kind === "error" ? String(event.payload.message ?? "") : event.kind === "run_complete" ? String(event.payload.error ?? "") : "";
2515
+ if (message && SIGNED_OUT.test(message))
2516
+ this.signedOutAgents.set(event.agentId, message.split("\n")[0].slice(0, 200));
2517
+ }
2518
+ /**
2519
+ * The size of an agent's native session in a chat — the context its last
2520
+ * turn sent — and how long ago that was; a resumed session past the cache's
2521
+ * life is re-read in full (#280).
2522
+ */
2523
+ sessionWeight(agentId, chat = MAIN_CHAT) {
2524
+ const last = this.log.list({ chat, agentId, kinds: ["run_complete"], limit: 50 }).at(-1);
2525
+ if (!last)
2526
+ return null;
2527
+ const tokens = Number(last.payload.inputTokens ?? 0) || 0;
2528
+ const model = typeof last.payload.model === "string" ? last.payload.model : undefined;
2529
+ const ageMs = Date.now() - last.ts;
2530
+ return { tokens, ageMs, ...(model ? { model } : {}), ...coldResume({ tokens, ageMs, ...(model ? { model } : {}) }, priceBook()) };
2531
+ }
2532
+ /** The agents a real turn found signed out, with what the CLI said (for `loom doctor`). */
2533
+ learnedSignOuts() {
2534
+ return [...this.signedOutAgents.entries()].map(([agentId, message]) => ({ agentId, kind: this.config.agents.find((a) => a.id === agentId)?.kind ?? "", message }));
2535
+ }
2536
+ /** What a restart would forget: each agent's latest sign-in failure that no successful turn has cleared since. */
2537
+ rehydrateSignIns() {
2538
+ for (const e of this.log.list({ kinds: ["error", "run_complete"], limit: 3000 }))
2539
+ this.noteSignIn(e);
2540
+ }
2541
+ /** What status says about an agent's sign-in: a real failure beats the CLI's own (cached) check. */
2542
+ signInState(agentId, kind) {
2543
+ const learned = this.signedOutAgents.get(agentId);
2544
+ if (learned)
2545
+ return { signedOut: `${learned}${signInCommand(kind) ? ` — ${signInCommand(kind)}` : ""}` };
2546
+ if (!signInCommand(kind) || process.env.LOOM_NO_AUTH_PROBE)
2547
+ return {};
2548
+ const auth = knownAuth(kind);
2549
+ return auth?.authed === false ? { signedOut: `${auth.detail ?? "not signed in"} — ${signInCommand(kind)}` } : {};
2550
+ }
2423
2551
  shield(event) {
2424
2552
  if (!event.agentId)
2425
2553
  return;
@@ -2455,6 +2583,18 @@ export class ProjectRuntime {
2455
2583
  }
2456
2584
  /** The loop card's buttons: stop the turn, nudge the agent, or hand the task to another agent. */
2457
2585
  async actOnLoop(chat, agentId, action, to, loop) {
2586
+ if (action !== "stop") {
2587
+ const release = this.queueCoordinator.hold();
2588
+ try {
2589
+ return await this.actOnLoopNow(chat, agentId, action, to, loop);
2590
+ }
2591
+ finally {
2592
+ release();
2593
+ }
2594
+ }
2595
+ return this.actOnLoopNow(chat, agentId, action, to, loop);
2596
+ }
2597
+ async actOnLoopNow(chat, agentId, action, to, loop) {
2458
2598
  const card = loop ?? this.lastLoop(chat, agentId);
2459
2599
  await this.interrupt({ chat, source: "route" });
2460
2600
  if (action === "stop")
@@ -2697,25 +2837,42 @@ export class ProjectRuntime {
2697
2837
  });
2698
2838
  await this.ensureStarted(opts.agentId);
2699
2839
  const mcp = child.capabilities.mcp ? writeMcpSession(this.healthyMcps()) : null;
2840
+ const brief = this.subtaskBriefing(parentAgentId, opts.agentId, task);
2700
2841
  const input = {
2701
2842
  text: task,
2702
2843
  chat,
2703
- briefing: this.subtaskBriefing(parentAgentId, opts.agentId, task),
2844
+ briefing: opts.readOnly ? `${brief}\n\n${READ_ONLY_RULE}` : brief,
2845
+ ...(opts.readOnly && child instanceof ProviderAgent ? { interactionMode: "plan" } : {}),
2704
2846
  ...(mcp ? { mcp: { configPath: mcp.configPath, servers: mcp.servers } } : {}),
2705
2847
  };
2848
+ const startedAt = Date.now();
2849
+ // the child's reply belongs to the chat it was asked in, not wherever it last talked
2850
+ this.turns.turnChat.set(opts.agentId, chat);
2706
2851
  void child
2707
2852
  .send(input)
2708
2853
  .then(() => {
2709
2854
  this.subtasks.delete(id);
2855
+ // an adapter can report a failed turn as an error event and still resolve: that subtask
2856
+ // failed — it isn't "done" (#247)
2857
+ const failed = this.log.list({ chat, agentId: opts.agentId, kinds: ["error", "run_complete"], sinceTs: startedAt })
2858
+ .reverse().find((e) => e.kind === "error" || Boolean(e.payload.error));
2859
+ if (failed) {
2860
+ const message = String(failed.payload.message ?? failed.payload.error ?? "the turn failed").slice(0, 500);
2861
+ this.appendIfOpen({ kind: "subtask_failed", agentId: opts.agentId, chat, payload: { subtaskId: id, parent: parentAgentId, task, message } });
2862
+ opts.onSettled?.({ ok: false, message });
2863
+ return;
2864
+ }
2710
2865
  this.appendIfOpen({
2711
2866
  kind: "subtask_done",
2712
2867
  agentId: opts.agentId,
2713
2868
  chat,
2714
2869
  payload: { subtaskId: id, parent: parentAgentId, task },
2715
2870
  });
2871
+ opts.onSettled?.({ ok: true });
2716
2872
  })
2717
2873
  .catch((err) => {
2718
2874
  this.subtasks.delete(id);
2875
+ opts.onSettled?.({ ok: false, message: String(err instanceof Error ? err.message : err) });
2719
2876
  this.appendIfOpen({
2720
2877
  kind: "subtask_failed",
2721
2878
  agentId: opts.agentId,
@@ -2731,6 +2888,51 @@ export class ProjectRuntime {
2731
2888
  .finally(() => mcp?.cleanup());
2732
2889
  return { id, agentId: opts.agentId };
2733
2890
  }
2891
+ /**
2892
+ * `loom review`: these agents read the same thing at once, read-only, off
2893
+ * the baton (daemon/review.ts). The target is a file, free text, or —
2894
+ * by default — the uncommitted changes.
2895
+ */
2896
+ async startReview(input) {
2897
+ this.reviews.check(input.agents); // who, before reading any diff
2898
+ const target = this.reviewTarget(input);
2899
+ return this.reviews.start({ agents: input.agents, target, ...(input.focus ? { focus: input.focus } : {}), ...(input.chat ? { chat: input.chat } : {}) });
2900
+ }
2901
+ /** What a review reads: a file, free text, or the uncommitted changes. Throws when there's nothing to read. */
2902
+ reviewTarget(input) {
2903
+ let target;
2904
+ if (input.file?.trim()) {
2905
+ const rel = path.relative(this.info.dir, path.resolve(this.info.dir, input.file.trim()));
2906
+ if (rel.startsWith("..") || path.isAbsolute(rel))
2907
+ throw new Error(`${input.file} is outside the project`);
2908
+ if (!fs.existsSync(path.join(this.info.dir, rel)))
2909
+ throw new Error(`no file ${rel} in this project`);
2910
+ target = { kind: "file", file: rel.split(path.sep).join("/") };
2911
+ }
2912
+ else if (input.text?.trim()) {
2913
+ target = { kind: "text", text: input.text.trim() };
2914
+ }
2915
+ else {
2916
+ const git = (...args) => execFileSync("git", args, { cwd: this.info.dir, encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 15_000, stdio: ["ignore", "pipe", "ignore"] });
2917
+ let diff = "", untracked = [];
2918
+ try {
2919
+ try {
2920
+ diff = git("diff", "HEAD");
2921
+ }
2922
+ catch {
2923
+ diff = git("diff");
2924
+ } // no commits yet
2925
+ untracked = git("ls-files", "--others", "--exclude-standard").split("\n").filter(Boolean);
2926
+ }
2927
+ catch {
2928
+ throw new Error("--diff needs a git repository — review a --file instead");
2929
+ }
2930
+ if (!diff.trim() && !untracked.length)
2931
+ throw new Error("there are no uncommitted changes to review — name a --file instead");
2932
+ target = { kind: "diff", diff, untracked };
2933
+ }
2934
+ return target;
2935
+ }
2734
2936
  subtaskBriefing(parent, childId, task) { return this.briefings.subtaskBriefing(parent, childId, task); }
2735
2937
  defaultAdapterId() {
2736
2938
  const cfg = (this.config.defaultAgent &&
@@ -2984,6 +3186,8 @@ export class ProjectRuntime {
2984
3186
  // which chat that turn is in, so a client shows Stop only where it applies
2985
3187
  ...(isAdapter(live) && (live.busy() || this.turns.busySince.has(cfg.id)) ? { chat: this.turns.turnChat.get(cfg.id) ?? MAIN_CHAT } : {}),
2986
3188
  holdsBaton: holder === cfg.id,
3189
+ ...this.signInState(cfg.id, cfg.kind),
3190
+ ...(cfg.kind === "model" && !cfg.options?.write ? { readOnly: true } : {}),
2987
3191
  model,
2988
3192
  permissions: permissionFor(cfg.kind, cfg.options),
2989
3193
  enabled: true,
@@ -3027,8 +3231,24 @@ export class ProjectRuntime {
3027
3231
  // "the self-heal did nothing".
3028
3232
  quarantine: this.quarantined(),
3029
3233
  orchestra: this.orchestraSummary(),
3234
+ activity: this.inFlight(),
3030
3235
  };
3031
3236
  }
3237
+ /** What's in flight besides an agent's own turn — an orchestra planning, a crew, a council — so "idle" isn't said of a busy project (#294). */
3238
+ inFlight() {
3239
+ const out = [];
3240
+ if (this.orchestra.active())
3241
+ out.push("orchestra");
3242
+ for (const c of this.crews.workingCrews)
3243
+ out.push(`crew ${c}`);
3244
+ if (this.councils.active)
3245
+ out.push("council");
3246
+ if (this.shifts.active)
3247
+ out.push("night shift");
3248
+ if (this.reviews.running)
3249
+ out.push("review");
3250
+ return out;
3251
+ }
3032
3252
  /**
3033
3253
  * What every agent in this project is doing right now — for the fleet view.
3034
3254
  *
@@ -3131,6 +3351,17 @@ export class ProjectRuntime {
3131
3351
  * at the ceiling. Plan mode can't be known when an agent is built, so
3132
3352
  * `bypassRequiresPlan` treats it as off — the stricter reading.
3133
3353
  */
3354
+ /** Loom's Auto policy for this project, read live by agents (core/auto-policy.ts). */
3355
+ autoPolicy() {
3356
+ const dir = this.info.dir;
3357
+ const test = this.config.proof?.testCommand ?? detectTestCommand(dir);
3358
+ return {
3359
+ projectDir: dir,
3360
+ readPaths: (this.config.readPaths ?? []).filter((p) => typeof p === "string" && p.trim()).map((p) => path.resolve(dir, p)),
3361
+ // the project's own test command always runs: an agent has to be able to check its work
3362
+ allow: [...(this.config.permissions?.allow ?? []).filter((a) => typeof a === "string"), ...(test ? [test] : [])],
3363
+ };
3364
+ }
3134
3365
  policyOptions(cfg) {
3135
3366
  const opts = { ...(cfg.options ?? {}) };
3136
3367
  if (this.runnerMode) {
@@ -3252,9 +3483,11 @@ export class ProjectRuntime {
3252
3483
  };
3253
3484
  }
3254
3485
  async close() {
3486
+ this.unregisterAutoPolicy();
3255
3487
  await this.orchestra.shutdown().catch(() => { });
3256
3488
  this.relay.stop();
3257
3489
  this.shifts.close();
3490
+ this.councils.close();
3258
3491
  await this.crews.shutdown().catch(() => { });
3259
3492
  this.closed = true;
3260
3493
  this.harnesses.stop();