@loompad/cli 0.1.0
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/ARCHITECTURE.md +211 -0
- package/CHANGELOG.md +470 -0
- package/LICENSE +21 -0
- package/README.md +630 -0
- package/dist/adapters/base.d.ts +71 -0
- package/dist/adapters/base.js +187 -0
- package/dist/adapters/base.js.map +1 -0
- package/dist/adapters/bridges/antigravity.d.ts +53 -0
- package/dist/adapters/bridges/antigravity.js +106 -0
- package/dist/adapters/bridges/antigravity.js.map +1 -0
- package/dist/adapters/bridges/cdp.d.ts +82 -0
- package/dist/adapters/bridges/cdp.js +222 -0
- package/dist/adapters/bridges/cdp.js.map +1 -0
- package/dist/adapters/bridges/gui-chat.d.ts +113 -0
- package/dist/adapters/bridges/gui-chat.js +498 -0
- package/dist/adapters/bridges/gui-chat.js.map +1 -0
- package/dist/adapters/bridges/kiro.d.ts +19 -0
- package/dist/adapters/bridges/kiro.js +22 -0
- package/dist/adapters/bridges/kiro.js.map +1 -0
- package/dist/adapters/bridges/profiles.d.ts +82 -0
- package/dist/adapters/bridges/profiles.js +125 -0
- package/dist/adapters/bridges/profiles.js.map +1 -0
- package/dist/adapters/claude-code.d.ts +26 -0
- package/dist/adapters/claude-code.js +209 -0
- package/dist/adapters/claude-code.js.map +1 -0
- package/dist/adapters/codex.d.ts +51 -0
- package/dist/adapters/codex.js +291 -0
- package/dist/adapters/codex.js.map +1 -0
- package/dist/adapters/echo.d.ts +20 -0
- package/dist/adapters/echo.js +79 -0
- package/dist/adapters/echo.js.map +1 -0
- package/dist/adapters/grok.d.ts +61 -0
- package/dist/adapters/grok.js +217 -0
- package/dist/adapters/grok.js.map +1 -0
- package/dist/adapters/index.d.ts +9 -0
- package/dist/adapters/index.js +33 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/opencode.d.ts +64 -0
- package/dist/adapters/opencode.js +426 -0
- package/dist/adapters/opencode.js.map +1 -0
- package/dist/cli/common.d.ts +11 -0
- package/dist/cli/common.js +40 -0
- package/dist/cli/common.js.map +1 -0
- package/dist/cli/doctor.d.ts +12 -0
- package/dist/cli/doctor.js +181 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.js +641 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/tui-model.d.ts +73 -0
- package/dist/cli/tui-model.js +312 -0
- package/dist/cli/tui-model.js.map +1 -0
- package/dist/cli/tui.d.ts +15 -0
- package/dist/cli/tui.js +451 -0
- package/dist/cli/tui.js.map +1 -0
- package/dist/cli/ui.d.ts +10 -0
- package/dist/cli/ui.js +135 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/core/ades.d.ts +79 -0
- package/dist/core/ades.js +130 -0
- package/dist/core/ades.js.map +1 -0
- package/dist/core/baton.d.ts +37 -0
- package/dist/core/baton.js +92 -0
- package/dist/core/baton.js.map +1 -0
- package/dist/core/brain-extract.d.ts +111 -0
- package/dist/core/brain-extract.js +248 -0
- package/dist/core/brain-extract.js.map +1 -0
- package/dist/core/brain-index.d.ts +128 -0
- package/dist/core/brain-index.js +338 -0
- package/dist/core/brain-index.js.map +1 -0
- package/dist/core/brain.d.ts +214 -0
- package/dist/core/brain.js +425 -0
- package/dist/core/brain.js.map +1 -0
- package/dist/core/claude-cli.d.ts +12 -0
- package/dist/core/claude-cli.js +47 -0
- package/dist/core/claude-cli.js.map +1 -0
- package/dist/core/distill.d.ts +19 -0
- package/dist/core/distill.js +88 -0
- package/dist/core/distill.js.map +1 -0
- package/dist/core/eventlog.d.ts +43 -0
- package/dist/core/eventlog.js +211 -0
- package/dist/core/eventlog.js.map +1 -0
- package/dist/core/git.d.ts +203 -0
- package/dist/core/git.js +460 -0
- package/dist/core/git.js.map +1 -0
- package/dist/core/logbook.d.ts +59 -0
- package/dist/core/logbook.js +118 -0
- package/dist/core/logbook.js.map +1 -0
- package/dist/core/memory.d.ts +34 -0
- package/dist/core/memory.js +124 -0
- package/dist/core/memory.js.map +1 -0
- package/dist/core/notify.d.ts +11 -0
- package/dist/core/notify.js +30 -0
- package/dist/core/notify.js.map +1 -0
- package/dist/core/projection.d.ts +20 -0
- package/dist/core/projection.js +96 -0
- package/dist/core/projection.js.map +1 -0
- package/dist/core/registry.d.ts +76 -0
- package/dist/core/registry.js +147 -0
- package/dist/core/registry.js.map +1 -0
- package/dist/core/router.d.ts +42 -0
- package/dist/core/router.js +99 -0
- package/dist/core/router.js.map +1 -0
- package/dist/core/routes.d.ts +93 -0
- package/dist/core/routes.js +399 -0
- package/dist/core/routes.js.map +1 -0
- package/dist/core/search.d.ts +63 -0
- package/dist/core/search.js +0 -0
- package/dist/core/search.js.map +1 -0
- package/dist/core/setup.d.ts +70 -0
- package/dist/core/setup.js +250 -0
- package/dist/core/setup.js.map +1 -0
- package/dist/core/suggestions.d.ts +15 -0
- package/dist/core/suggestions.js +38 -0
- package/dist/core/suggestions.js.map +1 -0
- package/dist/core/worktree.d.ts +32 -0
- package/dist/core/worktree.js +83 -0
- package/dist/core/worktree.js.map +1 -0
- package/dist/daemon/app-page.d.ts +32 -0
- package/dist/daemon/app-page.js +6363 -0
- package/dist/daemon/app-page.js.map +1 -0
- package/dist/daemon/auth.d.ts +51 -0
- package/dist/daemon/auth.js +135 -0
- package/dist/daemon/auth.js.map +1 -0
- package/dist/daemon/board.d.ts +117 -0
- package/dist/daemon/board.js +202 -0
- package/dist/daemon/board.js.map +1 -0
- package/dist/daemon/brand-icons.d.ts +29 -0
- package/dist/daemon/brand-icons.js +44 -0
- package/dist/daemon/brand-icons.js.map +1 -0
- package/dist/daemon/client.d.ts +127 -0
- package/dist/daemon/client.js +237 -0
- package/dist/daemon/client.js.map +1 -0
- package/dist/daemon/geist-font.d.ts +10 -0
- package/dist/daemon/geist-font.js +783 -0
- package/dist/daemon/geist-font.js.map +1 -0
- package/dist/daemon/guards.d.ts +18 -0
- package/dist/daemon/guards.js +40 -0
- package/dist/daemon/guards.js.map +1 -0
- package/dist/daemon/linear.d.ts +59 -0
- package/dist/daemon/linear.js +117 -0
- package/dist/daemon/linear.js.map +1 -0
- package/dist/daemon/push.d.ts +22 -0
- package/dist/daemon/push.js +58 -0
- package/dist/daemon/push.js.map +1 -0
- package/dist/daemon/runtime.d.ts +299 -0
- package/dist/daemon/runtime.js +1016 -0
- package/dist/daemon/runtime.js.map +1 -0
- package/dist/daemon/server.d.ts +125 -0
- package/dist/daemon/server.js +1728 -0
- package/dist/daemon/server.js.map +1 -0
- package/dist/daemon/tasks.d.ts +153 -0
- package/dist/daemon/tasks.js +264 -0
- package/dist/daemon/tasks.js.map +1 -0
- package/dist/daemon/terminals.d.ts +82 -0
- package/dist/daemon/terminals.js +372 -0
- package/dist/daemon/terminals.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/sdk.d.ts +17 -0
- package/dist/sdk.js +17 -0
- package/dist/sdk.js.map +1 -0
- package/dist/types.d.ts +236 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/docs/SETUP.md +265 -0
- package/docs/adapters.md +132 -0
- package/docs/design-system.md +245 -0
- package/docs/img/board-linear.png +0 -0
- package/docs/img/board-projects.png +0 -0
- package/docs/img/brain.png +0 -0
- package/docs/img/settings.png +0 -0
- package/docs/img/workspace.png +0 -0
- package/docs/integration-notes.md +76 -0
- package/docs/proposals/brain.md +262 -0
- package/package.json +84 -0
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProjectRuntime — one live project inside the daemon: its event log, its
|
|
3
|
+
* agents, and its baton. All mutations flow through here so the log stays
|
|
4
|
+
* the single source of truth.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { isAdapter, MAIN_CHAT } from "../types.js";
|
|
9
|
+
import { createAgent, knownAgentKinds } from "../adapters/index.js";
|
|
10
|
+
import { BatonManager, NotHolderError } from "../core/baton.js";
|
|
11
|
+
import { Brain, CONFIDENCE_FLOOR } from "../core/brain.js";
|
|
12
|
+
import { compileBrief, retrieve } from "../core/brain-index.js";
|
|
13
|
+
import { extractFromTurn } from "../core/brain-extract.js";
|
|
14
|
+
import { claudeText } from "../core/claude-cli.js";
|
|
15
|
+
import { EventLog } from "../core/eventlog.js";
|
|
16
|
+
import { renderProjection } from "../core/distill.js";
|
|
17
|
+
import { buildUnifiedMemory, hashContent, readNativeMemory, } from "../core/memory.js";
|
|
18
|
+
import { notify } from "../core/notify.js";
|
|
19
|
+
import { RouteEngine } from "../core/routes.js";
|
|
20
|
+
import { buildBriefing, buildProjection } from "../core/projection.js";
|
|
21
|
+
import { newId, projectLoomDir, readProjectConfig, readProjectState, writeProjectConfig, writeProjectState, writeMemoryFile, } from "../core/registry.js";
|
|
22
|
+
import { suggestHandoff } from "../core/suggestions.js";
|
|
23
|
+
import { diffSinceSnapshot, porcelainStatus, workingTree, } from "../core/worktree.js";
|
|
24
|
+
const PROJECTION_WINDOW = 400; // recent events distilled on handoff
|
|
25
|
+
export class ProjectRuntime {
|
|
26
|
+
info;
|
|
27
|
+
config;
|
|
28
|
+
log;
|
|
29
|
+
baton;
|
|
30
|
+
routes;
|
|
31
|
+
/** Memory as units — see core/brain.ts. Reads and writes through `log`. */
|
|
32
|
+
brain;
|
|
33
|
+
agents = new Map();
|
|
34
|
+
startedAgents = new Set();
|
|
35
|
+
configMtime = 0;
|
|
36
|
+
/**
|
|
37
|
+
* Which conversation each agent's current turn belongs to. Set when a turn
|
|
38
|
+
* starts and left in place afterwards — an agent's trailing events (a late
|
|
39
|
+
* run_complete, a diff) still belong to the chat that prompted them.
|
|
40
|
+
*/
|
|
41
|
+
turnChat = new Map();
|
|
42
|
+
constructor(info, config, log) {
|
|
43
|
+
this.info = info;
|
|
44
|
+
this.config = config;
|
|
45
|
+
this.log = log;
|
|
46
|
+
this.baton = new BatonManager(info.dir, log);
|
|
47
|
+
this.brain = new Brain(log);
|
|
48
|
+
// Same path as addAgent: an agent added at runtime must behave exactly like
|
|
49
|
+
// one that was here at open, and two copies of this loop would drift.
|
|
50
|
+
// (An agent streams events long after send() returns and has no idea which
|
|
51
|
+
// conversation prompted it — spawnAgent tags them with the chat that started
|
|
52
|
+
// the turn, so a reply lands where the question was asked.)
|
|
53
|
+
for (const agentCfg of config.agents)
|
|
54
|
+
this.spawnAgent(agentCfg);
|
|
55
|
+
this.routes = new RouteEngine({
|
|
56
|
+
projectName: info.name,
|
|
57
|
+
projectDir: info.dir,
|
|
58
|
+
config,
|
|
59
|
+
log,
|
|
60
|
+
handoff: (to) => this.handoff(to, { source: "route" }),
|
|
61
|
+
send: (text, agentId) => this.sendMessage(text, agentId, { source: "route" }),
|
|
62
|
+
interrupt: () => this.interrupt({ source: "route" }),
|
|
63
|
+
costTotal: () => this.costs.totalUsd,
|
|
64
|
+
isAdapterId: (id) => {
|
|
65
|
+
const agent = this.agents.get(id);
|
|
66
|
+
return Boolean(agent && isAdapter(agent));
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
static async open(info) {
|
|
71
|
+
const config = readProjectConfig(info.dir);
|
|
72
|
+
if (!config)
|
|
73
|
+
throw new Error(`project at ${info.dir} has no .loom/config.json — run loom init`);
|
|
74
|
+
const log = await EventLog.open(projectLoomDir(info.dir));
|
|
75
|
+
const rt = new ProjectRuntime(info, config, log);
|
|
76
|
+
rt.configMtime = configMtimeOf(info.dir);
|
|
77
|
+
rt.rehydrateCosts();
|
|
78
|
+
// Pull each connected ADE's native memory into the shared brain on open.
|
|
79
|
+
try {
|
|
80
|
+
rt.importMemories();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Memory import is best-effort; never block opening a project.
|
|
84
|
+
}
|
|
85
|
+
return rt;
|
|
86
|
+
}
|
|
87
|
+
// -------------------------------------------------------------------------
|
|
88
|
+
// Cost telemetry — O(1) incremental, rehydrated from the log on open
|
|
89
|
+
// -------------------------------------------------------------------------
|
|
90
|
+
costs = { totalUsd: 0, turns: 0, totalMs: 0 };
|
|
91
|
+
costsByAgent = new Map();
|
|
92
|
+
rehydrateCosts() {
|
|
93
|
+
for (const event of this.log.list({ kinds: ["status", "run_complete"] })) {
|
|
94
|
+
this.trackCost(event);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
trackCost(event) {
|
|
98
|
+
const agentId = event.agentId ?? "unknown";
|
|
99
|
+
const entry = this.costsByAgent.get(agentId) ?? { usd: 0, turns: 0, ms: 0 };
|
|
100
|
+
if (event.kind === "status" && event.payload.state === "turn_cost") {
|
|
101
|
+
const usd = Number(event.payload.costUsd ?? 0);
|
|
102
|
+
if (usd > 0) {
|
|
103
|
+
this.costs.totalUsd += usd;
|
|
104
|
+
entry.usd += usd;
|
|
105
|
+
this.costsByAgent.set(agentId, entry);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else if (event.kind === "run_complete") {
|
|
109
|
+
const ms = Number(event.payload.durationMs ?? 0);
|
|
110
|
+
this.costs.turns += 1;
|
|
111
|
+
this.costs.totalMs += ms;
|
|
112
|
+
entry.turns += 1;
|
|
113
|
+
entry.ms += ms;
|
|
114
|
+
this.costsByAgent.set(agentId, entry);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
costSummary() {
|
|
118
|
+
const byAgent = [...this.costsByAgent.entries()]
|
|
119
|
+
.map(([agentId, c]) => ({ agentId, ...c }))
|
|
120
|
+
.sort((a, b) => b.usd - a.usd || b.turns - a.turns);
|
|
121
|
+
return {
|
|
122
|
+
totalUsd: this.costs.totalUsd,
|
|
123
|
+
turns: this.costs.turns,
|
|
124
|
+
totalMs: this.costs.totalMs,
|
|
125
|
+
byAgent,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Has .loom/config.json changed since this runtime was opened? */
|
|
129
|
+
configStale() {
|
|
130
|
+
return configMtimeOf(this.info.dir) > this.configMtime;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Rename an agent's job. Writes .loom/config.json (the source of truth) and
|
|
134
|
+
* updates this runtime in place — the generic hot-reload would do it too, but
|
|
135
|
+
* only once the project is quiet, and a label you just typed shouldn't wait
|
|
136
|
+
* on an agent's turn to finish. Nothing is torn down: a role is a name, not
|
|
137
|
+
* a capability, so no adapter needs restarting.
|
|
138
|
+
*/
|
|
139
|
+
setAgentRole(agentId, role) {
|
|
140
|
+
const cfg = this.config.agents.find((a) => a.id === agentId);
|
|
141
|
+
if (!cfg)
|
|
142
|
+
return null;
|
|
143
|
+
cfg.role = role;
|
|
144
|
+
this.saveConfig();
|
|
145
|
+
return { id: agentId, role };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Edit the project's settings the Settings screen owns — the brain extractor,
|
|
149
|
+
* the projection mode, the default agent. These were config-file-only until
|
|
150
|
+
* now; everything is read live from this.config (brain?.extractor at turn end,
|
|
151
|
+
* projection at handoff), so a merge here takes effect on the next turn/hop
|
|
152
|
+
* with no restart. Only the known keys are honoured; unknown ones are ignored.
|
|
153
|
+
*/
|
|
154
|
+
patchConfig(patch) {
|
|
155
|
+
// Validate everything that can be rejected BEFORE touching this.config, so a
|
|
156
|
+
// bad field can't leave a half-applied change in memory that the next save
|
|
157
|
+
// would then persist.
|
|
158
|
+
const wantsDefault = typeof patch.defaultAgent === "string";
|
|
159
|
+
const defaultId = wantsDefault ? patch.defaultAgent.trim() : "";
|
|
160
|
+
if (wantsDefault && defaultId && !this.config.agents.some((a) => a.id === defaultId)) {
|
|
161
|
+
throw new Error(`no agent "${defaultId}" in this project`);
|
|
162
|
+
}
|
|
163
|
+
if (patch.brain) {
|
|
164
|
+
const b = { ...(this.config.brain ?? {}) };
|
|
165
|
+
if (patch.brain.extractor === "auto" || patch.brain.extractor === "off")
|
|
166
|
+
b.extractor = patch.brain.extractor;
|
|
167
|
+
if (typeof patch.brain.model === "string")
|
|
168
|
+
b.model = patch.brain.model.trim() || undefined;
|
|
169
|
+
this.config.brain = b;
|
|
170
|
+
}
|
|
171
|
+
if (patch.projection) {
|
|
172
|
+
const pr = { ...(this.config.projection ?? {}) };
|
|
173
|
+
if (patch.projection.mode === "template" || patch.projection.mode === "llm")
|
|
174
|
+
pr.mode = patch.projection.mode;
|
|
175
|
+
if (typeof patch.projection.model === "string")
|
|
176
|
+
pr.model = patch.projection.model.trim() || undefined;
|
|
177
|
+
this.config.projection = pr;
|
|
178
|
+
}
|
|
179
|
+
if (wantsDefault) {
|
|
180
|
+
// empty clears it; a real value was checked against the roster above
|
|
181
|
+
if (!defaultId)
|
|
182
|
+
delete this.config.defaultAgent;
|
|
183
|
+
else
|
|
184
|
+
this.config.defaultAgent = defaultId;
|
|
185
|
+
}
|
|
186
|
+
this.saveConfig();
|
|
187
|
+
return this.config;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The slice of config the Settings screen edits, read back for display: the
|
|
191
|
+
* brain extractor, the projection mode, the default agent, and the roster the
|
|
192
|
+
* default-agent picker chooses from. Defaults are spelled out here (extractor
|
|
193
|
+
* "auto", projection "template") so the screen shows the effective value, not
|
|
194
|
+
* a blank that hides what's actually running.
|
|
195
|
+
*/
|
|
196
|
+
settings() {
|
|
197
|
+
return {
|
|
198
|
+
brain: {
|
|
199
|
+
extractor: this.config.brain?.extractor === "off" ? "off" : "auto",
|
|
200
|
+
model: this.config.brain?.model ?? "",
|
|
201
|
+
},
|
|
202
|
+
projection: {
|
|
203
|
+
mode: this.config.projection?.mode === "llm" ? "llm" : "template",
|
|
204
|
+
model: this.config.projection?.model ?? "",
|
|
205
|
+
},
|
|
206
|
+
defaultAgent: this.config.defaultAgent ?? "",
|
|
207
|
+
agents: this.config.agents.map((a) => ({ id: a.id, kind: a.kind, role: a.role })),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Put an agent in this project.
|
|
212
|
+
*
|
|
213
|
+
* Until this existed a project's roster was whatever was detected the moment
|
|
214
|
+
* it was created, forever. Install a new ADE and your existing projects never
|
|
215
|
+
* heard about it — which is why a machine with six agents had boards offering
|
|
216
|
+
* two, and looked like a bug in the board.
|
|
217
|
+
*
|
|
218
|
+
* The role defaults to the kind, which is a description rather than an
|
|
219
|
+
* opinion: Loom has no basis for deciding Codex is "the reviewer".
|
|
220
|
+
*/
|
|
221
|
+
addAgent(kind, opts = {}) {
|
|
222
|
+
if (!knownAgentKinds().includes(kind)) {
|
|
223
|
+
throw new Error(`unknown agent kind "${kind}" (known: ${knownAgentKinds().join(", ")})`);
|
|
224
|
+
}
|
|
225
|
+
const id = (opts.id ?? kind).trim().slice(0, 40);
|
|
226
|
+
if (!id)
|
|
227
|
+
throw new Error("an agent needs an id");
|
|
228
|
+
if (this.config.agents.some((a) => a.id === id)) {
|
|
229
|
+
throw new Error(`"${id}" is already in this project`);
|
|
230
|
+
}
|
|
231
|
+
const cfg = { id, kind, role: (opts.role ?? kind).trim().slice(0, 40) || kind };
|
|
232
|
+
// Build it before saving. A config entry with no live agent behind it makes
|
|
233
|
+
// status() throw the moment anything asks — this.agent(id) doesn't find it —
|
|
234
|
+
// so the project 500s on every poll and the roster you just changed becomes
|
|
235
|
+
// unreachable. Writing the file is the easy half; the runtime has to learn
|
|
236
|
+
// too, and it can't wait for a restart to do it.
|
|
237
|
+
this.spawnAgent(cfg);
|
|
238
|
+
this.config.agents.push(cfg);
|
|
239
|
+
this.saveConfig();
|
|
240
|
+
return cfg;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Create one agent and subscribe to it, exactly as the constructor does.
|
|
244
|
+
*
|
|
245
|
+
* Shared so a roster change can't drift from a cold start: an agent added at
|
|
246
|
+
* runtime must stream its events into the log the same way as one that was
|
|
247
|
+
* there when the project opened.
|
|
248
|
+
*/
|
|
249
|
+
spawnAgent(cfg) {
|
|
250
|
+
const agent = createAgent(cfg, this.info.dir);
|
|
251
|
+
this.agents.set(cfg.id, agent);
|
|
252
|
+
agent.onEvent((e) => {
|
|
253
|
+
const chat = this.turnChat.get(agent.id);
|
|
254
|
+
const event = this.log.append({
|
|
255
|
+
kind: e.kind,
|
|
256
|
+
agentId: agent.id,
|
|
257
|
+
...(chat ? { chat } : {}),
|
|
258
|
+
payload: e.payload,
|
|
259
|
+
});
|
|
260
|
+
this.afterAgentEvent(event);
|
|
261
|
+
});
|
|
262
|
+
return agent;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Take an agent out.
|
|
266
|
+
*
|
|
267
|
+
* Refused while it holds the baton or is mid-turn: removing it there would
|
|
268
|
+
* strand the lock on an agent that no longer exists, and the thread would
|
|
269
|
+
* show a turn that nothing is running.
|
|
270
|
+
*/
|
|
271
|
+
removeAgent(agentId) {
|
|
272
|
+
const cfg = this.config.agents.find((a) => a.id === agentId);
|
|
273
|
+
if (!cfg)
|
|
274
|
+
throw new Error(`unknown agent "${agentId}"`);
|
|
275
|
+
const holder = this.validHolder();
|
|
276
|
+
if (holder === agentId) {
|
|
277
|
+
throw new Error(`"${agentId}" holds the baton — hand it to someone else first`);
|
|
278
|
+
}
|
|
279
|
+
const live = this.agents.get(agentId);
|
|
280
|
+
if (live && isAdapter(live) && live.busy()) {
|
|
281
|
+
throw new Error(`"${agentId}" is mid-turn — interrupt it first`);
|
|
282
|
+
}
|
|
283
|
+
// Its events stay in the log: the history happened, and a roster change
|
|
284
|
+
// doesn't unhappen it. Only the roster forgets.
|
|
285
|
+
this.config.agents = this.config.agents.filter((a) => a.id !== agentId);
|
|
286
|
+
this.saveConfig();
|
|
287
|
+
if (live) {
|
|
288
|
+
void Promise.resolve(live.stop()).catch(() => { });
|
|
289
|
+
this.agents.delete(agentId);
|
|
290
|
+
}
|
|
291
|
+
return { removed: agentId };
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Point an agent at a different model.
|
|
295
|
+
*
|
|
296
|
+
* The model is read once, when the adapter is constructed (createAgent hands
|
|
297
|
+
* it cfg.options), so changing it means building a fresh agent — which drops
|
|
298
|
+
* the CLI session the old one was resuming. That's the right behaviour for a
|
|
299
|
+
* model switch: continuing one model's conversation on another model is not a
|
|
300
|
+
* thing the underlying CLIs support anyway. Refused mid-turn, because swapping
|
|
301
|
+
* the process out from under a running turn would strand it.
|
|
302
|
+
*
|
|
303
|
+
* An empty model clears the override, so the CLI falls back to its own default
|
|
304
|
+
* — the honest "Default" the picker offers.
|
|
305
|
+
*/
|
|
306
|
+
setAgentModel(agentId, model) {
|
|
307
|
+
const cfg = this.config.agents.find((a) => a.id === agentId);
|
|
308
|
+
if (!cfg)
|
|
309
|
+
throw new Error(`unknown agent "${agentId}"`);
|
|
310
|
+
const live = this.agents.get(agentId);
|
|
311
|
+
if (live && isAdapter(live) && live.busy()) {
|
|
312
|
+
throw new Error(`"${agentId}" is mid-turn — wait for it to finish, then switch models`);
|
|
313
|
+
}
|
|
314
|
+
const next = model.trim().slice(0, 80);
|
|
315
|
+
const options = { ...(cfg.options ?? {}) };
|
|
316
|
+
if (next)
|
|
317
|
+
options.model = next;
|
|
318
|
+
else
|
|
319
|
+
delete options.model;
|
|
320
|
+
cfg.options = options;
|
|
321
|
+
// Rebuild so the new model actually takes: stop the old process, spawn a
|
|
322
|
+
// replacement subscribed exactly as the constructor's loop does.
|
|
323
|
+
if (live) {
|
|
324
|
+
void Promise.resolve(live.stop()).catch(() => { });
|
|
325
|
+
this.agents.delete(agentId);
|
|
326
|
+
}
|
|
327
|
+
this.spawnAgent(cfg);
|
|
328
|
+
this.saveConfig();
|
|
329
|
+
return cfg;
|
|
330
|
+
}
|
|
331
|
+
/** Write the roster, without tripping our own staleness check. */
|
|
332
|
+
saveConfig() {
|
|
333
|
+
writeProjectConfig(this.info.dir, this.config);
|
|
334
|
+
// we just wrote the file, so don't let configStale() see our own write and
|
|
335
|
+
// schedule a pointless reload
|
|
336
|
+
this.configMtime = configMtimeOf(this.info.dir);
|
|
337
|
+
}
|
|
338
|
+
// -------------------------------------------------------------------------
|
|
339
|
+
// Chats — several conversations, one brain
|
|
340
|
+
// -------------------------------------------------------------------------
|
|
341
|
+
/**
|
|
342
|
+
* Every conversation in this project, main first. Main is implicit: it's
|
|
343
|
+
* always there and it owns every event written before chats existed, so it
|
|
344
|
+
* is never stored. The rest live in state.json — a chat you created and
|
|
345
|
+
* haven't spoken in yet has no events to derive it from.
|
|
346
|
+
*/
|
|
347
|
+
chats() {
|
|
348
|
+
const stored = readProjectState(this.info.dir).chats ?? [];
|
|
349
|
+
return [
|
|
350
|
+
{ id: MAIN_CHAT, title: "Main", createdAt: 0 },
|
|
351
|
+
...stored.filter((c) => c.id !== MAIN_CHAT),
|
|
352
|
+
];
|
|
353
|
+
}
|
|
354
|
+
createChat(title) {
|
|
355
|
+
const state = readProjectState(this.info.dir);
|
|
356
|
+
const chat = {
|
|
357
|
+
id: newId(4),
|
|
358
|
+
// numbered, not "New chat" — the button already says New chat, and a
|
|
359
|
+
// sidebar of identical rows tells you nothing
|
|
360
|
+
title: title.trim().slice(0, 60) || `Chat ${(state.chats ?? []).length + 2}`,
|
|
361
|
+
createdAt: Date.now(),
|
|
362
|
+
};
|
|
363
|
+
state.chats = [...(state.chats ?? []), chat];
|
|
364
|
+
writeProjectState(this.info.dir, state);
|
|
365
|
+
return chat;
|
|
366
|
+
}
|
|
367
|
+
renameChat(id, title) {
|
|
368
|
+
if (id === MAIN_CHAT)
|
|
369
|
+
return null; // main's name is not yours to change
|
|
370
|
+
const state = readProjectState(this.info.dir);
|
|
371
|
+
const chat = (state.chats ?? []).find((c) => c.id === id);
|
|
372
|
+
if (!chat)
|
|
373
|
+
return null;
|
|
374
|
+
chat.title = title.trim().slice(0, 60) || chat.title;
|
|
375
|
+
writeProjectState(this.info.dir, state);
|
|
376
|
+
return chat;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Forget a conversation. Its events stay in the log — it's append-only, and
|
|
380
|
+
* the brain is built from all of them; deleting the thread you had with an
|
|
381
|
+
* agent shouldn't quietly rewrite what the project decided. The chat just
|
|
382
|
+
* stops being listed.
|
|
383
|
+
*/
|
|
384
|
+
deleteChat(id) {
|
|
385
|
+
if (id === MAIN_CHAT)
|
|
386
|
+
return false; // there is always a main chat
|
|
387
|
+
const state = readProjectState(this.info.dir);
|
|
388
|
+
const before = (state.chats ?? []).length;
|
|
389
|
+
state.chats = (state.chats ?? []).filter((c) => c.id !== id);
|
|
390
|
+
if (state.chats.length === before)
|
|
391
|
+
return false;
|
|
392
|
+
writeProjectState(this.info.dir, state);
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
// -------------------------------------------------------------------------
|
|
396
|
+
// Board tasks — the cards you write yourself
|
|
397
|
+
// -------------------------------------------------------------------------
|
|
398
|
+
boardTasks() {
|
|
399
|
+
return readProjectState(this.info.dir).tasks ?? [];
|
|
400
|
+
}
|
|
401
|
+
createTask(input) {
|
|
402
|
+
const state = readProjectState(this.info.dir);
|
|
403
|
+
const task = {
|
|
404
|
+
id: newId(4),
|
|
405
|
+
title: input.title.trim().slice(0, 200),
|
|
406
|
+
column: input.column ?? "working",
|
|
407
|
+
...(input.agent ? { agent: input.agent } : {}),
|
|
408
|
+
createdAt: Date.now(),
|
|
409
|
+
};
|
|
410
|
+
state.tasks = [...(state.tasks ?? []), task];
|
|
411
|
+
writeProjectState(this.info.dir, state);
|
|
412
|
+
return task;
|
|
413
|
+
}
|
|
414
|
+
/** Move or retitle a card. Yours, so this is the real state — not a hint. */
|
|
415
|
+
updateTask(id, patch) {
|
|
416
|
+
const state = readProjectState(this.info.dir);
|
|
417
|
+
const task = (state.tasks ?? []).find((t) => t.id === id);
|
|
418
|
+
if (!task)
|
|
419
|
+
return null;
|
|
420
|
+
if (patch.title !== undefined)
|
|
421
|
+
task.title = patch.title.trim().slice(0, 200) || task.title;
|
|
422
|
+
if (patch.column !== undefined)
|
|
423
|
+
task.column = patch.column;
|
|
424
|
+
if (patch.agent !== undefined)
|
|
425
|
+
task.agent = patch.agent;
|
|
426
|
+
writeProjectState(this.info.dir, state);
|
|
427
|
+
return task;
|
|
428
|
+
}
|
|
429
|
+
deleteTask(id) {
|
|
430
|
+
const state = readProjectState(this.info.dir);
|
|
431
|
+
const before = (state.tasks ?? []).length;
|
|
432
|
+
state.tasks = (state.tasks ?? []).filter((t) => t.id !== id);
|
|
433
|
+
if (state.tasks.length === before)
|
|
434
|
+
return false;
|
|
435
|
+
writeProjectState(this.info.dir, state);
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
/** Any adapter mid-turn? (Hot reloads are deferred while work is in flight.) */
|
|
439
|
+
anyBusy() {
|
|
440
|
+
return [...this.agents.values()].some((a) => isAdapter(a) && a.busy());
|
|
441
|
+
}
|
|
442
|
+
agent(id) {
|
|
443
|
+
const agent = this.agents.get(id);
|
|
444
|
+
if (!agent)
|
|
445
|
+
throw new Error(`unknown agent "${id}" in project "${this.info.name}"`);
|
|
446
|
+
return agent;
|
|
447
|
+
}
|
|
448
|
+
async ensureStarted(agentId) {
|
|
449
|
+
const agent = this.agent(agentId);
|
|
450
|
+
if (!this.startedAgents.has(agentId)) {
|
|
451
|
+
await agent.start();
|
|
452
|
+
this.startedAgents.add(agentId);
|
|
453
|
+
}
|
|
454
|
+
return agent;
|
|
455
|
+
}
|
|
456
|
+
/** Pre-turn porcelain snapshots, for per-prompt diff attribution. */
|
|
457
|
+
preTurnTree = new Map();
|
|
458
|
+
/** After a turn: log which files that prompt changed (turn_diff), then learn. */
|
|
459
|
+
captureTurnDiff(agentId) {
|
|
460
|
+
const before = this.preTurnTree.get(agentId);
|
|
461
|
+
if (before === undefined) {
|
|
462
|
+
// No snapshot (e.g. a turn with no pre-tree) — still worth reading.
|
|
463
|
+
this.extractMemory(agentId, []);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
this.preTurnTree.delete(agentId);
|
|
467
|
+
void diffSinceSnapshot(this.info.dir, before)
|
|
468
|
+
.then((diff) => {
|
|
469
|
+
if (diff) {
|
|
470
|
+
this.log.append({
|
|
471
|
+
kind: "turn_diff",
|
|
472
|
+
agentId,
|
|
473
|
+
payload: {
|
|
474
|
+
files: diff.files,
|
|
475
|
+
added: diff.added,
|
|
476
|
+
removed: diff.removed,
|
|
477
|
+
patch: diff.patch,
|
|
478
|
+
truncated: diff.truncated,
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
// Learn from the turn once we know which files it touched — the files
|
|
483
|
+
// sharpen candidate retrieval. Runs after the diff so recentTurnFiles
|
|
484
|
+
// isn't needed; the files are right here.
|
|
485
|
+
this.extractMemory(agentId, (diff?.files ?? []).map((f) => f.path));
|
|
486
|
+
})
|
|
487
|
+
.catch(() => this.extractMemory(agentId, []));
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Phase 2: read a finished turn for durable memory.
|
|
491
|
+
*
|
|
492
|
+
* Fire-and-forget on purpose. A slow or missing extractor must never delay
|
|
493
|
+
* anything — extractFromTurn already swallows engine failures, and this is
|
|
494
|
+
* void-ed so even an unexpected throw can't escape into the event pipeline.
|
|
495
|
+
* Off entirely when config says so; a no-op when Claude isn't available.
|
|
496
|
+
*/
|
|
497
|
+
extractMemory(agentId, files) {
|
|
498
|
+
if (this.config.brain?.extractor === "off")
|
|
499
|
+
return;
|
|
500
|
+
const chat = this.turnChat.get(agentId) ?? MAIN_CHAT;
|
|
501
|
+
const turn = this.gatherTurnText(chat);
|
|
502
|
+
if (turn.length < 40)
|
|
503
|
+
return; // nothing substantial to learn from
|
|
504
|
+
const model = this.config.brain?.model ?? "haiku";
|
|
505
|
+
const engine = (p) => claudeText(`${p.system}\n\n${p.user}`, { model, timeoutMs: 60_000 });
|
|
506
|
+
void extractFromTurn(this.brain, turn, {
|
|
507
|
+
engine,
|
|
508
|
+
agentId,
|
|
509
|
+
chat,
|
|
510
|
+
...(files.length ? { files } : {}),
|
|
511
|
+
eventId: this.log.lastId(),
|
|
512
|
+
})
|
|
513
|
+
.then((res) => {
|
|
514
|
+
const learned = res.added.length + res.updated.length + res.forgotten.length;
|
|
515
|
+
if (learned > 0) {
|
|
516
|
+
this.log.append({
|
|
517
|
+
kind: "status",
|
|
518
|
+
payload: {
|
|
519
|
+
state: "brain_extract",
|
|
520
|
+
agentId,
|
|
521
|
+
added: res.added.length,
|
|
522
|
+
updated: res.updated.length,
|
|
523
|
+
forgotten: res.forgotten.length,
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
})
|
|
528
|
+
.catch(() => { });
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* The transcript of the most recent turn in a chat: from the last human
|
|
532
|
+
* message to now — the user's ask and what the agent did in reply.
|
|
533
|
+
*/
|
|
534
|
+
gatherTurnText(chat) {
|
|
535
|
+
const events = this.log.list({ chat, limit: 40 });
|
|
536
|
+
let start = 0;
|
|
537
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
538
|
+
if (events[i].kind === "message" && !events[i].agentId) {
|
|
539
|
+
start = i;
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const lines = [];
|
|
544
|
+
for (const e of events.slice(start)) {
|
|
545
|
+
const p = e.payload;
|
|
546
|
+
if (e.kind === "message") {
|
|
547
|
+
lines.push(`${e.agentId ?? "user"}: ${String(p.text ?? "").slice(0, 2000)}`);
|
|
548
|
+
}
|
|
549
|
+
else if (e.kind === "tool_call") {
|
|
550
|
+
lines.push(`[${e.agentId} used ${String(p.tool ?? "a tool")}] ${String(p.summary ?? "")}`.trim());
|
|
551
|
+
}
|
|
552
|
+
else if (e.kind === "file_edit") {
|
|
553
|
+
lines.push(`[${e.agentId} edited ${String(p.path ?? "")}]`);
|
|
554
|
+
}
|
|
555
|
+
else if (e.kind === "decision") {
|
|
556
|
+
lines.push(`decision: ${String(p.text ?? "")}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return lines.join("\n").trim();
|
|
560
|
+
}
|
|
561
|
+
workingTree() {
|
|
562
|
+
return workingTree(this.info.dir);
|
|
563
|
+
}
|
|
564
|
+
// -------------------------------------------------------------------------
|
|
565
|
+
// Unified memory — "multiple memory in one"
|
|
566
|
+
// -------------------------------------------------------------------------
|
|
567
|
+
/** Freshly read every connected ADE's native memory from disk. */
|
|
568
|
+
importedMemory() {
|
|
569
|
+
return readNativeMemory(this.info.dir, this.config);
|
|
570
|
+
}
|
|
571
|
+
/** The merged brain: decisions + imported ADE memories + shared context. */
|
|
572
|
+
unifiedMemory() {
|
|
573
|
+
return buildUnifiedMemory(this.info.name, this.log.list(), this.importedMemory());
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Phase 3: the brain brief for a handoff — the memories relevant to the work
|
|
577
|
+
* in flight, compiled. Query is the recent conversation plus the files recent
|
|
578
|
+
* turns touched; scoped to the incoming agent; low-confidence memories are
|
|
579
|
+
* held back from injection (they stay visible in the Brain tab). Empty string
|
|
580
|
+
* when there's nothing relevant, so callers append it unconditionally.
|
|
581
|
+
*/
|
|
582
|
+
retrieveBrief(events, agentId) {
|
|
583
|
+
const query = events
|
|
584
|
+
.filter((e) => e.kind === "message")
|
|
585
|
+
.slice(-8)
|
|
586
|
+
.map((e) => String(e.payload.text ?? ""))
|
|
587
|
+
.join(" ");
|
|
588
|
+
const files = [
|
|
589
|
+
...new Set(events
|
|
590
|
+
.filter((e) => e.kind === "turn_diff")
|
|
591
|
+
.flatMap((e) => {
|
|
592
|
+
// turn_diff stores ChangedFile[] ({status, path}); older events or
|
|
593
|
+
// other shapes may carry bare strings. Normalise to paths.
|
|
594
|
+
const raw = e.payload.files ?? [];
|
|
595
|
+
return raw.map((f) => (typeof f === "string" ? f : (f?.path ?? ""))).filter(Boolean);
|
|
596
|
+
})),
|
|
597
|
+
].slice(-20);
|
|
598
|
+
if (!query.trim() && !files.length)
|
|
599
|
+
return "";
|
|
600
|
+
const hits = retrieve(this.brain, {
|
|
601
|
+
...(query.trim() ? { query } : {}),
|
|
602
|
+
...(files.length ? { files } : {}),
|
|
603
|
+
agent: agentId,
|
|
604
|
+
minConfidence: CONFIDENCE_FLOOR,
|
|
605
|
+
limit: 14,
|
|
606
|
+
});
|
|
607
|
+
return compileBrief(hits.map((h) => h.memory));
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Pull each ADE's native memory into the shared log. Idempotent — a source
|
|
611
|
+
* whose content hasn't changed since its last import is skipped, so this is
|
|
612
|
+
* safe to call on connect, on demand, or on a timer.
|
|
613
|
+
*/
|
|
614
|
+
importMemories() {
|
|
615
|
+
const seen = new Map(); // file -> last imported hash
|
|
616
|
+
for (const e of this.log.list({ kinds: ["memory_import"] })) {
|
|
617
|
+
seen.set(String(e.payload.file), String(e.payload.hash));
|
|
618
|
+
}
|
|
619
|
+
const sources = [];
|
|
620
|
+
let imported = 0;
|
|
621
|
+
for (const block of this.importedMemory()) {
|
|
622
|
+
const hash = hashContent(block.content);
|
|
623
|
+
if (seen.get(block.file) === hash)
|
|
624
|
+
continue;
|
|
625
|
+
this.log.append({
|
|
626
|
+
kind: "memory_import",
|
|
627
|
+
agentId: block.agentId,
|
|
628
|
+
payload: { file: block.file, kind: block.kind, chars: block.content.length, hash },
|
|
629
|
+
});
|
|
630
|
+
sources.push(block.file);
|
|
631
|
+
imported += 1;
|
|
632
|
+
}
|
|
633
|
+
return { imported, sources };
|
|
634
|
+
}
|
|
635
|
+
/** Fire-and-notify hooks + routing + suggested handoffs, off the log. */
|
|
636
|
+
afterAgentEvent(event) {
|
|
637
|
+
this.trackCost(event);
|
|
638
|
+
this.routes.handleAgentEvent(event);
|
|
639
|
+
if (event.kind === "run_complete" && event.agentId) {
|
|
640
|
+
this.captureTurnDiff(event.agentId);
|
|
641
|
+
}
|
|
642
|
+
if (event.kind === "needs_input") {
|
|
643
|
+
notify({
|
|
644
|
+
title: `Loom · ${this.info.name}`,
|
|
645
|
+
body: `${event.agentId} needs input: ${String(event.payload.question ?? "")}`,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
else if (event.kind === "run_complete") {
|
|
649
|
+
notify({
|
|
650
|
+
title: `Loom · ${this.info.name}`,
|
|
651
|
+
body: `${event.agentId} finished its turn`,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
else if (event.kind === "message") {
|
|
655
|
+
if (event.agentId)
|
|
656
|
+
this.captureDecisions(event);
|
|
657
|
+
if (!this.routes.isActive()) {
|
|
658
|
+
// A route drives its own handoffs — suggestions would be noise.
|
|
659
|
+
const suggestion = suggestHandoff(event, this.config, this.baton.holder());
|
|
660
|
+
if (suggestion) {
|
|
661
|
+
this.log.append({ kind: "suggestion", payload: { ...suggestion, from: event.agentId } });
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Convention: any agent line starting "Decision: …" is pinned into shared
|
|
668
|
+
* memory automatically — it survives every future handoff projection.
|
|
669
|
+
*/
|
|
670
|
+
captureDecisions(event) {
|
|
671
|
+
const text = String(event.payload.text ?? "");
|
|
672
|
+
const matches = [...text.matchAll(/^[ \t]*decision:\s*(.+)$/gim)].slice(0, 5);
|
|
673
|
+
for (const m of matches) {
|
|
674
|
+
this.log.append({
|
|
675
|
+
kind: "decision",
|
|
676
|
+
payload: { text: m[1].trim(), author: event.agentId, auto: true },
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* The persisted holder, unless it refers to an agent that has since been
|
|
682
|
+
* removed from the config — ghost holders are cleared, not fatal.
|
|
683
|
+
*/
|
|
684
|
+
validHolder() {
|
|
685
|
+
const holder = this.baton.holder();
|
|
686
|
+
if (holder && !this.agents.has(holder)) {
|
|
687
|
+
this.baton.forceClear(`agent "${holder}" no longer in config`);
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
return holder;
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Send a user message. Routing rules (decided in the design interview):
|
|
694
|
+
* - no explicit agent → goes to the baton holder (or defaultAgent/first
|
|
695
|
+
* adapter on first contact, which acquires the baton);
|
|
696
|
+
* - explicit agent that is NOT the holder → NotHolderError; surfaces
|
|
697
|
+
* prompt the user to confirm a handoff (explicit, never silent).
|
|
698
|
+
*/
|
|
699
|
+
/**
|
|
700
|
+
* Hand a prompt to a GUI agent by typing it into its own window.
|
|
701
|
+
*
|
|
702
|
+
* This is the road not taken by sendMessage. Antigravity and Kiro can't hold
|
|
703
|
+
* the baton — they edit the tree on their own schedule and know nothing about
|
|
704
|
+
* Loom's lock, so giving them the baton would be a promise Loom can't keep.
|
|
705
|
+
* But they can be *driven*: Loom types into the chat panel of the app you're
|
|
706
|
+
* already signed into, exactly as you would, and reads back what appeared.
|
|
707
|
+
* That's what makes them reachable from your phone.
|
|
708
|
+
*
|
|
709
|
+
* The exchange lands in the thread like any other, because the whole point of
|
|
710
|
+
* Loom is one place where you can see what was said to whom. It just never
|
|
711
|
+
* touches the baton on the way, so an adapter mid-turn is undisturbed.
|
|
712
|
+
*
|
|
713
|
+
* Awaited, unlike sendMessage's fire-and-notify: there's no event stream to
|
|
714
|
+
* follow here, only a panel that stops changing.
|
|
715
|
+
*/
|
|
716
|
+
async askBridge(agentId, text, opts = {}) {
|
|
717
|
+
const chat = opts.chat ?? MAIN_CHAT;
|
|
718
|
+
const agent = this.agent(agentId);
|
|
719
|
+
if (isAdapter(agent)) {
|
|
720
|
+
throw new Error(`agent "${agentId}" takes turns — send to it normally`);
|
|
721
|
+
}
|
|
722
|
+
const bridge = agent;
|
|
723
|
+
if (typeof bridge.ask !== "function") {
|
|
724
|
+
throw new Error(`agent "${agentId}" can be watched but not driven`);
|
|
725
|
+
}
|
|
726
|
+
await this.ensureStarted(agentId);
|
|
727
|
+
this.turnChat.set(agentId, chat);
|
|
728
|
+
this.log.append({ kind: "message", chat, agentId, payload: { text, author: "user" } });
|
|
729
|
+
try {
|
|
730
|
+
const reply = await bridge.ask(text);
|
|
731
|
+
return { agentId, reply };
|
|
732
|
+
}
|
|
733
|
+
catch (err) {
|
|
734
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
735
|
+
// The bridge's own words: "signed out", "launch it with…". They're the
|
|
736
|
+
// actionable part, and burying them behind "bridge failed" helps nobody.
|
|
737
|
+
this.log.append({ kind: "error", chat, agentId, payload: { message } });
|
|
738
|
+
throw err;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
async sendMessage(text, agentId, opts = {}) {
|
|
742
|
+
const source = opts.source ?? "user";
|
|
743
|
+
const chat = opts.chat ?? MAIN_CHAT;
|
|
744
|
+
let target = agentId ?? this.validHolder() ?? this.defaultAdapterId();
|
|
745
|
+
const agent = this.agent(target);
|
|
746
|
+
if (!isAdapter(agent)) {
|
|
747
|
+
throw new Error(`agent "${target}" is a bridge (read-only) — it cannot take turns`);
|
|
748
|
+
}
|
|
749
|
+
const holder = this.validHolder();
|
|
750
|
+
if (holder === null) {
|
|
751
|
+
this.baton.acquire(target);
|
|
752
|
+
}
|
|
753
|
+
else if (holder !== target) {
|
|
754
|
+
throw new NotHolderError(target, holder);
|
|
755
|
+
}
|
|
756
|
+
// A user reply to a paused route's question resumes the route.
|
|
757
|
+
if (source === "user")
|
|
758
|
+
this.routes.onUserMessage(target);
|
|
759
|
+
// everything this turn produces belongs to the chat you sent from
|
|
760
|
+
this.turnChat.set(target, chat);
|
|
761
|
+
this.log.append({
|
|
762
|
+
kind: "message",
|
|
763
|
+
chat,
|
|
764
|
+
payload: { text, author: source === "route" ? "loom" : "user" },
|
|
765
|
+
});
|
|
766
|
+
await this.ensureStarted(target);
|
|
767
|
+
const pendingBriefing = this.consumePendingBriefing(target);
|
|
768
|
+
const input = pendingBriefing ? { text, briefing: pendingBriefing } : { text };
|
|
769
|
+
// Snapshot the tree so this prompt's changes can be attributed to it.
|
|
770
|
+
this.preTurnTree.set(target, await porcelainStatus(this.info.dir));
|
|
771
|
+
// Fire-and-notify: the turn runs in the background; progress streams
|
|
772
|
+
// into the log and completion lands as run_complete.
|
|
773
|
+
void agent.send(input).catch((err) => {
|
|
774
|
+
this.log.append({
|
|
775
|
+
kind: "error",
|
|
776
|
+
agentId: target,
|
|
777
|
+
payload: { message: String(err instanceof Error ? err.message : err) },
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
return { agentId: target };
|
|
781
|
+
}
|
|
782
|
+
defaultAdapterId() {
|
|
783
|
+
const cfg = (this.config.defaultAgent &&
|
|
784
|
+
this.config.agents.find((a) => a.id === this.config.defaultAgent)) ||
|
|
785
|
+
this.config.agents.find((a) => isAdapter(this.agent(a.id)));
|
|
786
|
+
if (!cfg)
|
|
787
|
+
throw new Error(`project "${this.info.name}" has no full-duplex adapters`);
|
|
788
|
+
return cfg.id;
|
|
789
|
+
}
|
|
790
|
+
// -------------------------------------------------------------------------
|
|
791
|
+
// Handoff
|
|
792
|
+
// -------------------------------------------------------------------------
|
|
793
|
+
/** Briefings are injected with the first turn after a handoff. */
|
|
794
|
+
pendingBriefings = new Map();
|
|
795
|
+
consumePendingBriefing(agentId) {
|
|
796
|
+
const briefing = this.pendingBriefings.get(agentId);
|
|
797
|
+
this.pendingBriefings.delete(agentId);
|
|
798
|
+
return briefing;
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Explicit baton pass: interrupt the current holder if mid-turn, project
|
|
802
|
+
* the log into the target's namespaced memory, arm the one-shot briefing.
|
|
803
|
+
* A *manual* handoff cancels any active route — the human outranks it.
|
|
804
|
+
*/
|
|
805
|
+
async handoff(to, opts = {}) {
|
|
806
|
+
const target = this.agent(to);
|
|
807
|
+
if (!isAdapter(target)) {
|
|
808
|
+
throw new Error(`cannot hand the baton to "${to}" — bridges are read-only by design`);
|
|
809
|
+
}
|
|
810
|
+
if ((opts.source ?? "user") === "user")
|
|
811
|
+
this.routes.onManualHandoff();
|
|
812
|
+
// Audit trail: snapshot the outgoing holder's working-tree state into the
|
|
813
|
+
// handoff event, so "who left what uncommitted" is always answerable.
|
|
814
|
+
let handoffMeta = { projected: true };
|
|
815
|
+
const holder = this.validHolder();
|
|
816
|
+
if (holder && holder !== to) {
|
|
817
|
+
const current = this.agent(holder);
|
|
818
|
+
if (isAdapter(current)) {
|
|
819
|
+
if (current.busy())
|
|
820
|
+
await current.interrupt();
|
|
821
|
+
const diff = await current.diff().catch(() => "");
|
|
822
|
+
if (diff)
|
|
823
|
+
handoffMeta = { ...handoffMeta, dirty: true, diff: diff.slice(0, 2000) };
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
// Refresh the shared brain from every ADE's native memory before handing
|
|
827
|
+
// off, so the incoming agent inherits what the others knew.
|
|
828
|
+
this.importMemories();
|
|
829
|
+
const events = this.log.list({ limit: PROJECTION_WINDOW });
|
|
830
|
+
const input = {
|
|
831
|
+
projectName: this.info.name,
|
|
832
|
+
config: this.config,
|
|
833
|
+
events,
|
|
834
|
+
targetAgentId: to,
|
|
835
|
+
fromAgentId: holder,
|
|
836
|
+
};
|
|
837
|
+
// Template by default; LLM-distilled when the project opts in — always
|
|
838
|
+
// falling back to the template so a broken Claude never blocks a handoff.
|
|
839
|
+
const distillStart = Date.now();
|
|
840
|
+
const rendered = await renderProjection(input, this.config.projection);
|
|
841
|
+
// Phase 3: the memories relevant to the work in flight, retrieved and
|
|
842
|
+
// compiled — this is the part the recency-window projection can't do. Query
|
|
843
|
+
// is the recent conversation plus the files recent turns touched; scoped to
|
|
844
|
+
// this chat and to the incoming agent; low-confidence memories are held back
|
|
845
|
+
// from injection (they're still visible in the Brain tab).
|
|
846
|
+
const brainBrief = this.retrieveBrief(events, to);
|
|
847
|
+
// Append the unified cross-ADE memory so the incoming agent sees the
|
|
848
|
+
// whole brain, not just this project's log.
|
|
849
|
+
const unified = this.unifiedMemory();
|
|
850
|
+
const parts = [rendered.content];
|
|
851
|
+
if (brainBrief)
|
|
852
|
+
parts.push(brainBrief);
|
|
853
|
+
if (unified.sources.length > 0)
|
|
854
|
+
parts.push(unified.document);
|
|
855
|
+
const enriched = parts.join("\n\n---\n");
|
|
856
|
+
await target.injectMemory(enriched);
|
|
857
|
+
writeMemoryFile(this.info.dir, to, enriched); // idempotent with default impl
|
|
858
|
+
this.pendingBriefings.set(to, buildBriefing(input));
|
|
859
|
+
if (rendered.mode === "llm") {
|
|
860
|
+
this.log.append({
|
|
861
|
+
kind: "status",
|
|
862
|
+
payload: { state: "projection", mode: "llm", ms: Date.now() - distillStart },
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
// Bridges (GUI agents) are passive observers — keep their shared-context
|
|
866
|
+
// files fresh on every hop so e.g. Antigravity always sees the weave.
|
|
867
|
+
// (Always template views: N bridges × LLM calls per hop would be waste.)
|
|
868
|
+
for (const cfg of this.config.agents) {
|
|
869
|
+
const bystander = this.agents.get(cfg.id);
|
|
870
|
+
if (!bystander || isAdapter(bystander) || cfg.id === to)
|
|
871
|
+
continue;
|
|
872
|
+
// Bridges get the retrieved brain brief too — they can't take a system
|
|
873
|
+
// prompt, but their shared-context file is the only memory they have, so
|
|
874
|
+
// it shouldn't be the one view without the learned memories in it.
|
|
875
|
+
const bridgeBrief = this.retrieveBrief(events, cfg.id);
|
|
876
|
+
const bridgeView = bridgeBrief
|
|
877
|
+
? `${buildProjection({ ...input, targetAgentId: cfg.id })}\n\n---\n${bridgeBrief}`
|
|
878
|
+
: buildProjection({ ...input, targetAgentId: cfg.id });
|
|
879
|
+
await bystander.injectMemory(bridgeView).catch(() => { });
|
|
880
|
+
}
|
|
881
|
+
const { from } = this.baton.handoff(to, handoffMeta);
|
|
882
|
+
await this.ensureStarted(to);
|
|
883
|
+
return { from };
|
|
884
|
+
}
|
|
885
|
+
async interrupt(opts = {}) {
|
|
886
|
+
if ((opts.source ?? "user") === "user")
|
|
887
|
+
this.routes.onManualInterrupt();
|
|
888
|
+
const holder = this.validHolder();
|
|
889
|
+
if (!holder)
|
|
890
|
+
return { interrupted: null };
|
|
891
|
+
const agent = this.agent(holder);
|
|
892
|
+
if (isAdapter(agent) && agent.busy()) {
|
|
893
|
+
await agent.interrupt();
|
|
894
|
+
return { interrupted: holder };
|
|
895
|
+
}
|
|
896
|
+
return { interrupted: null };
|
|
897
|
+
}
|
|
898
|
+
// -------------------------------------------------------------------------
|
|
899
|
+
// Routing
|
|
900
|
+
// -------------------------------------------------------------------------
|
|
901
|
+
/**
|
|
902
|
+
* Start a multi-hop route. `spec` may be: "auto" (dynamic — a router picks
|
|
903
|
+
* every hop), an array of steps, a named route from config, or a comma
|
|
904
|
+
* list of agent ids/roles. Undefined → the "ship" route if defined, else
|
|
905
|
+
* every adapter in config order.
|
|
906
|
+
*/
|
|
907
|
+
async startRoute(opts) {
|
|
908
|
+
if (typeof opts.spec === "string" && opts.spec.trim() === "auto") {
|
|
909
|
+
return this.routes.startDynamic(opts.task, {
|
|
910
|
+
...(opts.router ? { router: opts.router } : {}),
|
|
911
|
+
...(opts.maxHops ? { maxHops: opts.maxHops } : {}),
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
let steps;
|
|
915
|
+
let name;
|
|
916
|
+
if (Array.isArray(opts.spec)) {
|
|
917
|
+
steps = opts.spec;
|
|
918
|
+
}
|
|
919
|
+
else if (typeof opts.spec === "string" && opts.spec.trim()) {
|
|
920
|
+
const named = this.config.routes?.[opts.spec.trim()];
|
|
921
|
+
if (named) {
|
|
922
|
+
steps = named;
|
|
923
|
+
name = opts.spec.trim();
|
|
924
|
+
}
|
|
925
|
+
else {
|
|
926
|
+
steps = opts.spec.split(",").map((s) => s.trim()).filter(Boolean);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
else {
|
|
930
|
+
const ship = this.config.routes?.["ship"];
|
|
931
|
+
if (ship) {
|
|
932
|
+
steps = ship;
|
|
933
|
+
name = "ship";
|
|
934
|
+
}
|
|
935
|
+
else {
|
|
936
|
+
steps = this.config.agents
|
|
937
|
+
.filter((a) => {
|
|
938
|
+
const agent = this.agents.get(a.id);
|
|
939
|
+
return agent && isAdapter(agent);
|
|
940
|
+
})
|
|
941
|
+
.map((a) => a.id);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return this.routes.start(steps ?? [], opts.task, name);
|
|
945
|
+
}
|
|
946
|
+
async abortRoute() {
|
|
947
|
+
return this.routes.abort();
|
|
948
|
+
}
|
|
949
|
+
routeState() {
|
|
950
|
+
return this.routes.state();
|
|
951
|
+
}
|
|
952
|
+
// -------------------------------------------------------------------------
|
|
953
|
+
// Status / board
|
|
954
|
+
// -------------------------------------------------------------------------
|
|
955
|
+
async status() {
|
|
956
|
+
const holder = this.validHolder();
|
|
957
|
+
const agents = await Promise.all(this.config.agents.map(async (cfg) => {
|
|
958
|
+
const agent = this.agent(cfg.id);
|
|
959
|
+
return {
|
|
960
|
+
id: cfg.id,
|
|
961
|
+
kind: cfg.kind,
|
|
962
|
+
role: cfg.role,
|
|
963
|
+
tier: agent.capabilities.tier,
|
|
964
|
+
available: await agent.available().catch(() => false),
|
|
965
|
+
busy: isAdapter(agent) ? agent.busy() : false,
|
|
966
|
+
holdsBaton: holder === cfg.id,
|
|
967
|
+
// The picker shows a tick next to the active model; "" means the
|
|
968
|
+
// adapter's own default, which is the honest baseline.
|
|
969
|
+
model: cfg.options?.model ?? "",
|
|
970
|
+
};
|
|
971
|
+
}));
|
|
972
|
+
const recent = this.log.list({ limit: 50 });
|
|
973
|
+
const lastEvent = recent[recent.length - 1] ?? null;
|
|
974
|
+
const lastUserMsg = [...recent]
|
|
975
|
+
.reverse()
|
|
976
|
+
.find((e) => e.kind === "message" && !e.agentId);
|
|
977
|
+
const lastNeedsInput = [...recent].reverse().find((e) => e.kind === "needs_input");
|
|
978
|
+
const needsInput = Boolean(lastNeedsInput && (!lastUserMsg || lastNeedsInput.id > lastUserMsg.id));
|
|
979
|
+
return {
|
|
980
|
+
id: this.info.id,
|
|
981
|
+
name: this.info.name,
|
|
982
|
+
dir: this.info.dir,
|
|
983
|
+
holder,
|
|
984
|
+
agents,
|
|
985
|
+
lastEvent,
|
|
986
|
+
needsInput,
|
|
987
|
+
// which agent is waiting, not just that someone is — the board needs a
|
|
988
|
+
// name to put on the card, and every caller already gets needsInput
|
|
989
|
+
blockedAgent: needsInput ? (lastNeedsInput?.agentId ?? null) : null,
|
|
990
|
+
chats: this.chats(),
|
|
991
|
+
route: this.routes.state(),
|
|
992
|
+
routeNames: ["auto", ...Object.keys(this.config.routes ?? {})],
|
|
993
|
+
costUsd: this.costs.totalUsd,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
async close() {
|
|
997
|
+
for (const id of this.startedAgents) {
|
|
998
|
+
await this.agent(id).stop().catch(() => { });
|
|
999
|
+
}
|
|
1000
|
+
this.startedAgents.clear();
|
|
1001
|
+
this.brain.close(); // unsubscribes before the log drops its listeners
|
|
1002
|
+
this.log.close();
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
export function relativeToProject(projectDir, p) {
|
|
1006
|
+
return path.isAbsolute(p) ? path.relative(projectDir, p) : p;
|
|
1007
|
+
}
|
|
1008
|
+
function configMtimeOf(projectDir) {
|
|
1009
|
+
try {
|
|
1010
|
+
return fs.statSync(path.join(projectDir, ".loom", "config.json")).mtimeMs;
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
return 0;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
//# sourceMappingURL=runtime.js.map
|