loadout-ai 0.7.0 → 0.9.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/CHANGELOG.md +148 -1
- package/README.md +160 -315
- package/SECURITY.md +21 -1
- package/catalog/discovered.json +31269 -26003
- package/catalog/packages.json +4 -4
- package/dist/src/cli.js +13 -0
- package/dist/src/commands/agents.js +3 -1
- package/dist/src/commands/catalog-candidate.js +150 -0
- package/dist/src/commands/catalog-workflows.js +147 -0
- package/dist/src/commands/catalog.js +9 -367
- package/dist/src/commands/coordinate.js +586 -0
- package/dist/src/commands/coordination-discussions.js +194 -0
- package/dist/src/commands/coordination-sessions.js +197 -0
- package/dist/src/commands/inventory.js +4 -2
- package/dist/src/core/agents/agent-inspection.js +26 -4
- package/dist/src/core/{routing → agents}/model-config.js +7 -2
- package/dist/src/core/catalog/catalog.js +1 -0
- package/dist/src/core/catalog/registry.js +44 -10
- package/dist/src/core/catalog/safety.js +36 -7
- package/dist/src/core/coordination/adapters/claude-code.js +154 -0
- package/dist/src/core/coordination/adapters/codex.js +137 -0
- package/dist/src/core/coordination/adapters/types.js +64 -0
- package/dist/src/core/coordination/auth.js +59 -0
- package/dist/src/core/coordination/bridge-lease.js +79 -0
- package/dist/src/core/coordination/conflict-preview.js +167 -0
- package/dist/src/core/coordination/contract-diff.js +106 -0
- package/dist/src/core/coordination/coordinator.js +552 -0
- package/dist/src/core/coordination/crash-recovery.js +169 -0
- package/dist/src/core/coordination/daemon.js +667 -0
- package/dist/src/core/coordination/discussion.js +340 -0
- package/dist/src/core/coordination/events.js +161 -0
- package/dist/src/core/coordination/http-api.js +118 -0
- package/dist/src/core/coordination/interrupt-policy.js +89 -0
- package/dist/src/core/coordination/lock.js +110 -0
- package/dist/src/core/coordination/mcp-server.js +424 -0
- package/dist/src/core/coordination/redaction.js +85 -0
- package/dist/src/core/coordination/replay.js +188 -0
- package/dist/src/core/coordination/retention.js +128 -0
- package/dist/src/core/coordination/runtime.js +21 -0
- package/dist/src/core/coordination/session-manager.js +366 -0
- package/dist/src/core/coordination/watcher.js +128 -0
- package/dist/src/core/{routing → delegation}/first-party-skills.js +25 -13
- package/dist/src/core/{routing → delegation}/handoff.js +130 -63
- package/dist/src/core/discovery/candidate-intelligence-evidence.js +108 -0
- package/dist/src/core/discovery/candidate-intelligence-types.js +1 -0
- package/dist/src/core/discovery/candidate-intelligence-validation.js +108 -0
- package/dist/src/core/discovery/candidate-intelligence.js +3 -214
- package/dist/src/core/discovery/community.js +12 -4
- package/dist/src/core/discovery/github-discovery.js +6 -2
- package/dist/src/core/discovery/private-discovery.js +6 -2
- package/dist/src/core/install/reconcile.js +1 -1
- package/dist/src/core/install/source.js +21 -7
- package/dist/src/core/install/uninstall.js +39 -3
- package/dist/src/core/reporting/cli-guide.js +10 -6
- package/dist/src/core/reporting/completion.js +61 -109
- package/dist/src/core/reporting/doctor.js +4 -6
- package/dist/src/core/runtime/bounded-json.js +65 -0
- package/dist/src/core/runtime/github.js +30 -16
- package/dist/src/core/runtime/mcp-recipes.js +1 -1
- package/dist/src/core/workspace/active-policy.js +1 -1
- package/docs/CANDIDATE_INTELLIGENCE.md +9 -2
- package/docs/CATALOG.md +1 -1
- package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +1 -1
- package/docs/DEMO_SCRIPT.md +19 -23
- package/docs/DISCOVERED.md +251 -250
- package/docs/FEATURE_TEST_MATRIX.md +10 -263
- package/docs/GITHUB_AUTHORIZATION.md +5 -0
- package/docs/LIVE_COLLABORATION.md +234 -0
- package/docs/PROVENANCE_AND_COMPARISON.md +1 -1
- package/docs/REFERENCE.md +163 -0
- package/docs/RELEASE_REVIEW.md +1 -2
- package/docs/TESTING.md +17 -1
- package/docs/USER_TEST_GUIDE.md +138 -3
- package/docs/assets/loadout-discover-activate.webp +0 -0
- package/docs/assets/loadout-handoff-coordinate.webp +0 -0
- package/docs/assets/loadout-social-preview.png +0 -0
- package/docs/decisions/001-coordination-jsonl-locking.md +35 -0
- package/docs/decisions/002-local-daemon-authentication.md +32 -0
- package/docs/decisions/003-bounded-agent-discussions.md +91 -0
- package/docs/specs/BOUNDED_AGENT_DISCUSSIONS.md +169 -0
- package/docs/superpowers/plans/2026-09-03-coordination-hardening.md +231 -0
- package/docs/superpowers/plans/2026-09-03-release-readiness.md +232 -0
- package/docs/superpowers/plans/2026-09-04-bounded-agent-discussions.md +121 -0
- package/package.json +18 -7
- package/skills/loadout-handoff/SKILL.md +238 -0
- package/MASTER_PLAN.md +0 -2207
- package/dist/src/core/routing/route.js +0 -539
- package/docs/ACTIVE_SET.md +0 -53
- package/docs/COMPATIBILITY_POLICY.md +0 -22
- package/docs/CONVERSION_AND_SANDBOX.md +0 -27
- package/docs/EVALUATION_PROTOCOL_V1.md +0 -300
- package/docs/HEAD_TO_HEAD_EVALUATION.md +0 -79
- package/docs/PROVIDER_CONFIGURATION.md +0 -45
- package/docs/README_RESEARCH.md +0 -36
- package/docs/REPOSITORY_STABILIZATION.md +0 -190
- package/docs/SAFE_UPDATE_DEMO.md +0 -25
- package/docs/SCHEMA_DECISIONS.md +0 -25
- package/docs/SUBMISSION_COPY.md +0 -90
- package/docs/TEAM_POLICY.md +0 -18
- package/docs/assets/loadout-workflow.png +0 -0
- package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +0 -283
- package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +0 -116
- package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +0 -469
- package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +0 -80
- package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +0 -55
- package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +0 -228
- package/skills/loadout-router/SKILL.md +0 -120
- /package/dist/src/core/{routing → agents}/credentials.js +0 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session manager for coordinated agent sessions.
|
|
3
|
+
*
|
|
4
|
+
* Tracks active sessions across providers, handles reconnection with
|
|
5
|
+
* snapshot replay, and routes coordination events as follow-up turns.
|
|
6
|
+
*/
|
|
7
|
+
import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { formatEventsForInjection } from "./adapters/types.js";
|
|
12
|
+
import { snapshot, readAfterCursor } from "./coordinator.js";
|
|
13
|
+
import { watchCoordination } from "./watcher.js";
|
|
14
|
+
import { isKillSwitchActive } from "./crash-recovery.js";
|
|
15
|
+
import { categorizeEvents, DEFAULT_POLICY, loadPolicy, } from "./interrupt-policy.js";
|
|
16
|
+
const COORD_DIR = ".handoff";
|
|
17
|
+
const SESSIONS_FILE = "sessions.json";
|
|
18
|
+
const agentSessionSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
sessionId: z.string().trim().min(1).max(1_024),
|
|
21
|
+
agent: z.string().trim().min(1).max(128),
|
|
22
|
+
provider: z.string().trim().min(1).max(128),
|
|
23
|
+
active: z.boolean(),
|
|
24
|
+
busy: z.boolean(),
|
|
25
|
+
cursor: z.number().int().min(-1),
|
|
26
|
+
startedAt: z.iso.datetime({ offset: true }),
|
|
27
|
+
cwd: z.string().min(1),
|
|
28
|
+
})
|
|
29
|
+
.strict();
|
|
30
|
+
const sessionStoreSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
sessions: z.array(agentSessionSchema).max(100),
|
|
33
|
+
updatedAt: z.iso.datetime({ offset: true }),
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
function errorCode(error) {
|
|
37
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
38
|
+
? String(error.code)
|
|
39
|
+
: undefined;
|
|
40
|
+
}
|
|
41
|
+
export class SessionManager {
|
|
42
|
+
sessions = new Map();
|
|
43
|
+
adapters = new Map();
|
|
44
|
+
pendingEvents = new Map();
|
|
45
|
+
watcher = null;
|
|
46
|
+
options;
|
|
47
|
+
policy = DEFAULT_POLICY;
|
|
48
|
+
autoTurnCounts = new Map();
|
|
49
|
+
constructor(options) {
|
|
50
|
+
if (options.maxAutoTurnsPerSession !== undefined &&
|
|
51
|
+
(!Number.isSafeInteger(options.maxAutoTurnsPerSession) ||
|
|
52
|
+
options.maxAutoTurnsPerSession < 1)) {
|
|
53
|
+
throw new Error("maxAutoTurnsPerSession must be a positive integer");
|
|
54
|
+
}
|
|
55
|
+
this.options = options;
|
|
56
|
+
for (const adapter of options.adapters) {
|
|
57
|
+
this.adapters.set(adapter.provider, adapter);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Start the session manager — load persisted sessions and watch for events. */
|
|
61
|
+
async start() {
|
|
62
|
+
await this.loadSessions();
|
|
63
|
+
this.policy = await loadPolicy(this.options.projectRoot);
|
|
64
|
+
if (this.options.autoSubmit !== false) {
|
|
65
|
+
this.watcher = await watchCoordination(this.options.projectRoot, {
|
|
66
|
+
onEvents: (events) => {
|
|
67
|
+
void this.handleEvents(events).catch((error) => {
|
|
68
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
69
|
+
console.error(`Session manager routing error: ${message}`);
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
onError: (err) => console.error(`Session manager watcher error: ${err.message}`),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Stop the session manager. */
|
|
77
|
+
async stop() {
|
|
78
|
+
this.watcher?.stop();
|
|
79
|
+
this.watcher = null;
|
|
80
|
+
}
|
|
81
|
+
/** Detect which agent CLIs are available. */
|
|
82
|
+
async detectProviders() {
|
|
83
|
+
const results = [];
|
|
84
|
+
for (const adapter of this.adapters.values()) {
|
|
85
|
+
const version = await adapter.detect();
|
|
86
|
+
if (version) {
|
|
87
|
+
results.push({ provider: adapter.provider, version });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return results;
|
|
91
|
+
}
|
|
92
|
+
/** Start a new agent session. */
|
|
93
|
+
async startSession(provider, cwd, prompt, timeout) {
|
|
94
|
+
const killSwitch = await isKillSwitchActive(this.options.projectRoot);
|
|
95
|
+
if (killSwitch.active) {
|
|
96
|
+
throw new Error(`Coordination kill switch is active${killSwitch.reason ? `: ${killSwitch.reason}` : ""}`);
|
|
97
|
+
}
|
|
98
|
+
const adapter = this.adapters.get(provider);
|
|
99
|
+
if (!adapter)
|
|
100
|
+
throw new Error(`No adapter for provider: ${provider}`);
|
|
101
|
+
const session = await adapter.start({
|
|
102
|
+
cwd,
|
|
103
|
+
prompt,
|
|
104
|
+
...(timeout === undefined ? {} : { timeout }),
|
|
105
|
+
});
|
|
106
|
+
this.sessions.set(session.sessionId, session);
|
|
107
|
+
await this.saveSessions();
|
|
108
|
+
// A tracking-only manager must never create an implicit paid turn.
|
|
109
|
+
// Bridge managers replay the current snapshot once start() is idle.
|
|
110
|
+
if (this.options.autoSubmit !== false &&
|
|
111
|
+
adapter.capabilities.canSubmitTurn) {
|
|
112
|
+
const snap = await snapshot(this.options.projectRoot, provider);
|
|
113
|
+
await this.submitEvents(session, snap.unackedForAgent);
|
|
114
|
+
}
|
|
115
|
+
return session;
|
|
116
|
+
}
|
|
117
|
+
/** Resume an existing session with snapshot replay. */
|
|
118
|
+
async resumeSession(provider, sessionId, cwd) {
|
|
119
|
+
const killSwitch = await isKillSwitchActive(this.options.projectRoot);
|
|
120
|
+
if (killSwitch.active) {
|
|
121
|
+
throw new Error(`Coordination kill switch is active${killSwitch.reason ? `: ${killSwitch.reason}` : ""}`);
|
|
122
|
+
}
|
|
123
|
+
const adapter = this.adapters.get(provider);
|
|
124
|
+
if (!adapter)
|
|
125
|
+
throw new Error(`No adapter for provider: ${provider}`);
|
|
126
|
+
if (!adapter.capabilities.canResume) {
|
|
127
|
+
throw new Error(`${provider} does not support session resumption`);
|
|
128
|
+
}
|
|
129
|
+
const tracked = this.sessions.get(sessionId);
|
|
130
|
+
if (!tracked || tracked.provider !== provider) {
|
|
131
|
+
throw new Error(`Session ${sessionId} is not tracked by Loadout`);
|
|
132
|
+
}
|
|
133
|
+
const session = await adapter.resume(sessionId, cwd);
|
|
134
|
+
session.cursor = tracked.cursor;
|
|
135
|
+
session.startedAt = tracked.startedAt;
|
|
136
|
+
this.sessions.set(session.sessionId, session);
|
|
137
|
+
// Replay missed events
|
|
138
|
+
if (adapter.capabilities.canSubmitTurn && session.cursor >= 0) {
|
|
139
|
+
const { events } = await readAfterCursor(this.options.projectRoot, session.cursor);
|
|
140
|
+
const relevant = events.filter((e) => e.from !== provider &&
|
|
141
|
+
(e.to === provider || e.to === "*") &&
|
|
142
|
+
e.type !== "ack");
|
|
143
|
+
if (relevant.length > 0) {
|
|
144
|
+
await this.submitEvents(session, relevant);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
await this.saveSessions();
|
|
148
|
+
return session;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Attach a session that was created by the provider rather than Loadout.
|
|
152
|
+
* This is the explicit bridge boundary: the user supplies the host session
|
|
153
|
+
* id, then Loadout can resume it and route future coordination turns.
|
|
154
|
+
*/
|
|
155
|
+
async attachSession(provider, sessionId, cwd) {
|
|
156
|
+
const killSwitch = await isKillSwitchActive(this.options.projectRoot);
|
|
157
|
+
if (killSwitch.active) {
|
|
158
|
+
throw new Error(`Coordination kill switch is active${killSwitch.reason ? `: ${killSwitch.reason}` : ""}`);
|
|
159
|
+
}
|
|
160
|
+
const adapter = this.adapters.get(provider);
|
|
161
|
+
if (!adapter)
|
|
162
|
+
throw new Error(`No adapter for provider: ${provider}`);
|
|
163
|
+
if (!adapter.capabilities.canResume) {
|
|
164
|
+
throw new Error(`${provider} does not support session resumption`);
|
|
165
|
+
}
|
|
166
|
+
const tracked = this.sessions.get(sessionId);
|
|
167
|
+
if (tracked && tracked.provider !== provider) {
|
|
168
|
+
throw new Error(`Session ${sessionId} is already tracked for ${tracked.provider}`);
|
|
169
|
+
}
|
|
170
|
+
const session = await adapter.resume(sessionId, cwd);
|
|
171
|
+
session.cursor = tracked?.cursor ?? -1;
|
|
172
|
+
session.startedAt = tracked?.startedAt ?? session.startedAt;
|
|
173
|
+
this.sessions.set(sessionId, session);
|
|
174
|
+
if (this.options.autoSubmit !== false) {
|
|
175
|
+
const snap = await snapshot(this.options.projectRoot, provider);
|
|
176
|
+
await this.submitEvents(session, snap.unackedForAgent);
|
|
177
|
+
}
|
|
178
|
+
await this.saveSessions();
|
|
179
|
+
return session;
|
|
180
|
+
}
|
|
181
|
+
/** Stop a session. */
|
|
182
|
+
async stopSession(sessionId) {
|
|
183
|
+
const session = this.sessions.get(sessionId);
|
|
184
|
+
if (!session)
|
|
185
|
+
return;
|
|
186
|
+
const adapter = this.adapters.get(session.provider);
|
|
187
|
+
if (adapter) {
|
|
188
|
+
await adapter.stop(session);
|
|
189
|
+
}
|
|
190
|
+
session.active = false;
|
|
191
|
+
await this.saveSessions();
|
|
192
|
+
}
|
|
193
|
+
/** Get all tracked sessions. */
|
|
194
|
+
getSessions() {
|
|
195
|
+
return [...this.sessions.values()];
|
|
196
|
+
}
|
|
197
|
+
/** Get active sessions only. */
|
|
198
|
+
getActiveSessions() {
|
|
199
|
+
return [...this.sessions.values()].filter((s) => s.active);
|
|
200
|
+
}
|
|
201
|
+
/** Read the most recent in-memory provider response without persisting it. */
|
|
202
|
+
getLastResponse(sessionId) {
|
|
203
|
+
const session = this.sessions.get(sessionId);
|
|
204
|
+
if (!session)
|
|
205
|
+
return undefined;
|
|
206
|
+
return this.adapters.get(session.provider)?.lastResponse?.(sessionId);
|
|
207
|
+
}
|
|
208
|
+
/** Submit a direct follow-up turn, denying it while the session is busy. */
|
|
209
|
+
async submitTurn(sessionId, message, timeout) {
|
|
210
|
+
if ((await isKillSwitchActive(this.options.projectRoot)).active) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
const session = this.sessions.get(sessionId);
|
|
214
|
+
if (!session) {
|
|
215
|
+
throw new Error(`Session ${sessionId} is not tracked by Loadout`);
|
|
216
|
+
}
|
|
217
|
+
if (!session.active)
|
|
218
|
+
return false;
|
|
219
|
+
const adapter = this.adapters.get(session.provider);
|
|
220
|
+
if (!adapter?.capabilities.canSubmitTurn)
|
|
221
|
+
return false;
|
|
222
|
+
if (session.busy)
|
|
223
|
+
return false;
|
|
224
|
+
const success = await adapter.submitTurn(session, {
|
|
225
|
+
message,
|
|
226
|
+
...(timeout === undefined ? {} : { timeout }),
|
|
227
|
+
});
|
|
228
|
+
if (success) {
|
|
229
|
+
this.options.onTurnCompleted?.(session, adapter.lastResponse?.(session.sessionId));
|
|
230
|
+
await this.flushPendingEvents(session);
|
|
231
|
+
}
|
|
232
|
+
await this.saveSessions();
|
|
233
|
+
return success;
|
|
234
|
+
}
|
|
235
|
+
/** Route coordination events to active sessions. */
|
|
236
|
+
async handleEvents(events) {
|
|
237
|
+
if ((await isKillSwitchActive(this.options.projectRoot)).active)
|
|
238
|
+
return;
|
|
239
|
+
const deliveries = [];
|
|
240
|
+
for (const session of this.sessions.values()) {
|
|
241
|
+
if (!session.active)
|
|
242
|
+
continue;
|
|
243
|
+
const adapter = this.adapters.get(session.provider);
|
|
244
|
+
if (!adapter)
|
|
245
|
+
continue;
|
|
246
|
+
// Filter events relevant to this session's agent
|
|
247
|
+
const relevant = events.filter((e) => e.from !== session.agent &&
|
|
248
|
+
(e.to === session.agent || e.to === "*") &&
|
|
249
|
+
e.type !== "ack");
|
|
250
|
+
if (relevant.length === 0)
|
|
251
|
+
continue;
|
|
252
|
+
deliveries.push(this.submitEvents(session, relevant));
|
|
253
|
+
}
|
|
254
|
+
await Promise.all(deliveries);
|
|
255
|
+
}
|
|
256
|
+
async submitEvents(session, events) {
|
|
257
|
+
if (events.length === 0)
|
|
258
|
+
return;
|
|
259
|
+
if ((await isKillSwitchActive(this.options.projectRoot)).active)
|
|
260
|
+
return;
|
|
261
|
+
const categorized = categorizeEvents(events, this.policy);
|
|
262
|
+
if (categorized.passive.length > 0) {
|
|
263
|
+
this.options.onPendingEvents?.(session, categorized.passive);
|
|
264
|
+
}
|
|
265
|
+
const deliverable = [
|
|
266
|
+
...categorized.immediate,
|
|
267
|
+
...categorized.boundary,
|
|
268
|
+
].sort((left, right) => left.seq - right.seq);
|
|
269
|
+
if (deliverable.length === 0)
|
|
270
|
+
return;
|
|
271
|
+
const adapter = this.adapters.get(session.provider);
|
|
272
|
+
if (!adapter?.capabilities.canSubmitTurn) {
|
|
273
|
+
this.options.onPendingEvents?.(session, deliverable);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const turns = this.autoTurnCounts.get(session.sessionId) ?? 0;
|
|
277
|
+
const maxTurns = this.options.maxAutoTurnsPerSession ?? 20;
|
|
278
|
+
if (turns >= maxTurns) {
|
|
279
|
+
this.options.onPendingEvents?.(session, deliverable);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (session.busy) {
|
|
283
|
+
this.queueEvents(session, deliverable);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const message = formatEventsForInjection(deliverable);
|
|
287
|
+
const success = await adapter.submitTurn(session, { message });
|
|
288
|
+
if (!success) {
|
|
289
|
+
if (session.busy) {
|
|
290
|
+
this.queueEvents(session, deliverable);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
this.options.onPendingEvents?.(session, deliverable);
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
this.autoTurnCounts.set(session.sessionId, turns + 1);
|
|
298
|
+
session.cursor = Math.max(session.cursor, deliverable[deliverable.length - 1].seq);
|
|
299
|
+
this.options.onTurnCompleted?.(session, adapter.lastResponse?.(session.sessionId));
|
|
300
|
+
await this.saveSessions();
|
|
301
|
+
await this.flushPendingEvents(session);
|
|
302
|
+
}
|
|
303
|
+
queueEvents(session, events) {
|
|
304
|
+
const pending = this.pendingEvents.get(session.sessionId) ?? [];
|
|
305
|
+
pending.push(...events);
|
|
306
|
+
this.pendingEvents.set(session.sessionId, pending);
|
|
307
|
+
this.options.onPendingEvents?.(session, events);
|
|
308
|
+
}
|
|
309
|
+
async flushPendingEvents(session) {
|
|
310
|
+
if (session.busy)
|
|
311
|
+
return;
|
|
312
|
+
const pending = this.pendingEvents.get(session.sessionId);
|
|
313
|
+
if (!pending?.length)
|
|
314
|
+
return;
|
|
315
|
+
this.pendingEvents.delete(session.sessionId);
|
|
316
|
+
await this.submitEvents(session, pending);
|
|
317
|
+
}
|
|
318
|
+
/** Load persisted session state. */
|
|
319
|
+
async loadSessions() {
|
|
320
|
+
let raw;
|
|
321
|
+
try {
|
|
322
|
+
raw = await readFile(join(this.options.projectRoot, COORD_DIR, SESSIONS_FILE), "utf-8");
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (errorCode(error) === "ENOENT")
|
|
326
|
+
return;
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
let decoded;
|
|
330
|
+
try {
|
|
331
|
+
decoded = JSON.parse(raw);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
throw new Error("Invalid persisted session state: expected JSON");
|
|
335
|
+
}
|
|
336
|
+
const parsed = sessionStoreSchema.safeParse(decoded);
|
|
337
|
+
if (!parsed.success) {
|
|
338
|
+
throw new Error(`Invalid persisted session state: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`);
|
|
339
|
+
}
|
|
340
|
+
for (const stored of parsed.data.sessions) {
|
|
341
|
+
const session = {
|
|
342
|
+
...stored,
|
|
343
|
+
// A process restart detaches every host session until explicitly resumed.
|
|
344
|
+
active: false,
|
|
345
|
+
busy: false,
|
|
346
|
+
};
|
|
347
|
+
this.sessions.set(session.sessionId, session);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/** Persist session state. */
|
|
351
|
+
async saveSessions() {
|
|
352
|
+
const dir = join(this.options.projectRoot, COORD_DIR);
|
|
353
|
+
await mkdir(dir, { recursive: true });
|
|
354
|
+
const store = {
|
|
355
|
+
sessions: [...this.sessions.values()],
|
|
356
|
+
updatedAt: new Date().toISOString(),
|
|
357
|
+
};
|
|
358
|
+
const path = join(dir, SESSIONS_FILE);
|
|
359
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
360
|
+
await writeFile(temporary, JSON.stringify(store, null, 2) + "\n", {
|
|
361
|
+
encoding: "utf-8",
|
|
362
|
+
mode: 0o600,
|
|
363
|
+
});
|
|
364
|
+
await rename(temporary, path);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live file watcher for coordination events.
|
|
3
|
+
*
|
|
4
|
+
* Watches `.handoff/coordination.jsonl` and calls back when new events appear.
|
|
5
|
+
* This is the "live" part of Phase 2 — no daemon, no SQLite, just fs.watch on
|
|
6
|
+
* the append-only log.
|
|
7
|
+
*/
|
|
8
|
+
import { watch } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { readCoordLog } from "./coordinator.js";
|
|
11
|
+
const COORD_DIR = ".handoff";
|
|
12
|
+
const COORD_LOG = "coordination.jsonl";
|
|
13
|
+
export async function watchCoordination(projectRoot, options) {
|
|
14
|
+
// Get initial state
|
|
15
|
+
const initial = await readCoordLog(projectRoot);
|
|
16
|
+
let cursor = options.cursor ?? initial.highSeq;
|
|
17
|
+
let debounceTimer;
|
|
18
|
+
let stopped = false;
|
|
19
|
+
async function checkForUpdates() {
|
|
20
|
+
if (stopped)
|
|
21
|
+
return;
|
|
22
|
+
try {
|
|
23
|
+
const log = await readCoordLog(projectRoot);
|
|
24
|
+
if (log.highSeq <= cursor)
|
|
25
|
+
return;
|
|
26
|
+
let events = log.events.filter((e) => e.seq > cursor);
|
|
27
|
+
// Filter for agent if specified
|
|
28
|
+
if (options.agent) {
|
|
29
|
+
const agent = options.agent;
|
|
30
|
+
events = events.filter((e) => e.to === agent || e.to === "*" || e.from === agent);
|
|
31
|
+
}
|
|
32
|
+
cursor = log.highSeq;
|
|
33
|
+
if (events.length > 0) {
|
|
34
|
+
options.onEvents(events, log.highSeq);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Debounced check — multiple rapid writes only trigger one read
|
|
42
|
+
function scheduleCheck() {
|
|
43
|
+
if (debounceTimer)
|
|
44
|
+
clearTimeout(debounceTimer);
|
|
45
|
+
debounceTimer = setTimeout(() => {
|
|
46
|
+
checkForUpdates();
|
|
47
|
+
}, 100);
|
|
48
|
+
}
|
|
49
|
+
// Watch the directory (more reliable than watching a single file across platforms)
|
|
50
|
+
let fsWatcher;
|
|
51
|
+
try {
|
|
52
|
+
fsWatcher = watch(join(projectRoot, COORD_DIR), { persistent: true }, (eventType, filename) => {
|
|
53
|
+
if (filename === COORD_LOG || filename === null) {
|
|
54
|
+
scheduleCheck();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Directory might not exist yet — watch the project root for its creation
|
|
60
|
+
fsWatcher = watch(projectRoot, { persistent: true }, (eventType, filename) => {
|
|
61
|
+
if (filename === COORD_DIR) {
|
|
62
|
+
scheduleCheck();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
fsWatcher.on("error", (error) => {
|
|
67
|
+
options.onError?.(error);
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
stop() {
|
|
71
|
+
stopped = true;
|
|
72
|
+
if (debounceTimer)
|
|
73
|
+
clearTimeout(debounceTimer);
|
|
74
|
+
fsWatcher.close();
|
|
75
|
+
},
|
|
76
|
+
get cursor() {
|
|
77
|
+
return cursor;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Format a live event for terminal display.
|
|
83
|
+
*/
|
|
84
|
+
export function formatLiveEvent(event) {
|
|
85
|
+
const time = event.timestamp.slice(11, 19);
|
|
86
|
+
const typeColors = {
|
|
87
|
+
contract: "\x1b[36m", // cyan
|
|
88
|
+
ownership: "\x1b[33m", // yellow
|
|
89
|
+
decision: "\x1b[35m", // magenta
|
|
90
|
+
update: "\x1b[32m", // green
|
|
91
|
+
ack: "\x1b[90m", // gray
|
|
92
|
+
task: "\x1b[34m", // blue
|
|
93
|
+
done: "\x1b[32m", // green
|
|
94
|
+
error: "\x1b[31m", // red
|
|
95
|
+
};
|
|
96
|
+
const color = typeColors[event.type] ?? "\x1b[0m";
|
|
97
|
+
const reset = "\x1b[0m";
|
|
98
|
+
const bold = "\x1b[1m";
|
|
99
|
+
const parts = [
|
|
100
|
+
`${bold}${time}${reset}`,
|
|
101
|
+
`${color}[${event.type}]${reset}`,
|
|
102
|
+
`${bold}${event.from}${reset}`,
|
|
103
|
+
event.to !== "*" ? `→ ${event.to}` : "",
|
|
104
|
+
event.description,
|
|
105
|
+
].filter(Boolean);
|
|
106
|
+
const lines = [parts.join(" ")];
|
|
107
|
+
// Show payload highlights for interesting event types
|
|
108
|
+
if (event.type === "contract" && event.payload) {
|
|
109
|
+
const p = event.payload;
|
|
110
|
+
lines.push(` 📋 ${p.name} rev${p.revision}${p.format ? ` (${p.format})` : ""}`);
|
|
111
|
+
}
|
|
112
|
+
if (event.type === "ownership" && event.payload) {
|
|
113
|
+
const p = event.payload;
|
|
114
|
+
lines.push(` 🔒 ${p.mode}: ${p.paths.join(", ")}`);
|
|
115
|
+
}
|
|
116
|
+
if (event.type === "update" && event.payload) {
|
|
117
|
+
const p = event.payload;
|
|
118
|
+
if (p.blockers?.length)
|
|
119
|
+
lines.push(` 🚫 Blockers: ${p.blockers.join(", ")}`);
|
|
120
|
+
if (p.next)
|
|
121
|
+
lines.push(` ⏭️ Next: ${p.next}`);
|
|
122
|
+
}
|
|
123
|
+
if (event.type === "decision" && event.payload) {
|
|
124
|
+
const p = event.payload;
|
|
125
|
+
lines.push(` 📌 ${p.rationale.slice(0, 120)}`);
|
|
126
|
+
}
|
|
127
|
+
return lines.join("\n");
|
|
128
|
+
}
|
|
@@ -2,11 +2,12 @@ import { cp, rm, stat } from "node:fs/promises";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { detectAgents } from "../agents/paths.js";
|
|
5
|
+
import { runMutationTransaction } from "../install/transaction.js";
|
|
5
6
|
export const FIRST_PARTY_SKILLS = [
|
|
6
7
|
{
|
|
7
|
-
id: "loadout-
|
|
8
|
-
displayName: "Loadout
|
|
9
|
-
summary: "Lets an agent
|
|
8
|
+
id: "loadout-handoff",
|
|
9
|
+
displayName: "Loadout Handoff",
|
|
10
|
+
summary: "Lets an agent pass tasks to your other agent and pick up what was left for it",
|
|
10
11
|
},
|
|
11
12
|
{
|
|
12
13
|
id: "loadout-curator",
|
|
@@ -74,18 +75,29 @@ export async function planFirstPartySkill(id, options = {}) {
|
|
|
74
75
|
return { skill, source, targets };
|
|
75
76
|
}
|
|
76
77
|
export async function applyFirstPartySkill(plan) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
78
|
+
const applied = await runMutationTransaction(async () => ({
|
|
79
|
+
targets: plan.targets.map((target) => target.destination),
|
|
80
|
+
value: plan,
|
|
81
|
+
}), async (freshPlan) => {
|
|
82
|
+
for (const target of freshPlan.targets) {
|
|
83
|
+
// Replace wholesale so a reinstall cannot leave files from an older
|
|
84
|
+
// version of the skill behind.
|
|
85
|
+
await rm(target.destination, { recursive: true, force: true });
|
|
86
|
+
await cp(freshPlan.source, target.destination, { recursive: true });
|
|
87
|
+
}
|
|
88
|
+
}, { label: `install first-party skill ${plan.skill.id}` });
|
|
89
|
+
return applied.snapshotId;
|
|
84
90
|
}
|
|
85
91
|
export async function removeFirstPartySkill(plan) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
92
|
+
const present = plan.targets.filter((target) => target.replacing);
|
|
93
|
+
const applied = await runMutationTransaction(async () => ({
|
|
94
|
+
targets: present.map((target) => target.destination),
|
|
95
|
+
value: present,
|
|
96
|
+
}), async (freshTargets) => {
|
|
97
|
+
for (const target of freshTargets)
|
|
98
|
+
await rm(target.destination, { recursive: true, force: true });
|
|
99
|
+
}, { label: `remove first-party skill ${plan.skill.id}` });
|
|
100
|
+
return applied.snapshotId;
|
|
89
101
|
}
|
|
90
102
|
export function formatFirstPartySkillList(installedIds = new Set()) {
|
|
91
103
|
const lines = ["Skills that ship with Loadout:", ""];
|