loadout-ai 0.8.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +76 -1
  2. package/README.md +149 -304
  3. package/SECURITY.md +21 -1
  4. package/catalog/discovered.json +27897 -25327
  5. package/catalog/packages.json +4 -4
  6. package/dist/src/cli.js +12 -4
  7. package/dist/src/commands/agents.js +3 -1
  8. package/dist/src/commands/catalog-candidate.js +150 -0
  9. package/dist/src/commands/catalog-workflows.js +147 -0
  10. package/dist/src/commands/catalog.js +9 -354
  11. package/dist/src/commands/coordinate.js +586 -0
  12. package/dist/src/commands/coordination-discussions.js +194 -0
  13. package/dist/src/commands/coordination-sessions.js +197 -0
  14. package/dist/src/commands/inventory.js +4 -2
  15. package/dist/src/core/{routing → agents}/model-config.js +7 -2
  16. package/dist/src/core/catalog/catalog.js +1 -0
  17. package/dist/src/core/catalog/registry.js +5 -19
  18. package/dist/src/core/coordination/adapters/claude-code.js +154 -0
  19. package/dist/src/core/coordination/adapters/codex.js +137 -0
  20. package/dist/src/core/coordination/adapters/types.js +64 -0
  21. package/dist/src/core/coordination/auth.js +59 -0
  22. package/dist/src/core/coordination/bridge-lease.js +79 -0
  23. package/dist/src/core/coordination/conflict-preview.js +167 -0
  24. package/dist/src/core/coordination/contract-diff.js +106 -0
  25. package/dist/src/core/coordination/coordinator.js +552 -0
  26. package/dist/src/core/coordination/crash-recovery.js +169 -0
  27. package/dist/src/core/coordination/daemon.js +667 -0
  28. package/dist/src/core/coordination/discussion.js +340 -0
  29. package/dist/src/core/coordination/events.js +161 -0
  30. package/dist/src/core/coordination/http-api.js +118 -0
  31. package/dist/src/core/coordination/interrupt-policy.js +89 -0
  32. package/dist/src/core/coordination/lock.js +110 -0
  33. package/dist/src/core/coordination/mcp-server.js +424 -0
  34. package/dist/src/core/coordination/redaction.js +85 -0
  35. package/dist/src/core/coordination/replay.js +188 -0
  36. package/dist/src/core/coordination/retention.js +128 -0
  37. package/dist/src/core/coordination/runtime.js +21 -0
  38. package/dist/src/core/coordination/session-manager.js +366 -0
  39. package/dist/src/core/coordination/watcher.js +128 -0
  40. package/dist/src/core/{routing → delegation}/first-party-skills.js +25 -13
  41. package/dist/src/core/{routing → delegation}/handoff.js +40 -9
  42. package/dist/src/core/discovery/candidate-intelligence-evidence.js +108 -0
  43. package/dist/src/core/discovery/candidate-intelligence-types.js +1 -0
  44. package/dist/src/core/discovery/candidate-intelligence-validation.js +108 -0
  45. package/dist/src/core/discovery/candidate-intelligence.js +3 -214
  46. package/dist/src/core/discovery/community.js +12 -4
  47. package/dist/src/core/discovery/github-discovery.js +6 -2
  48. package/dist/src/core/discovery/private-discovery.js +6 -2
  49. package/dist/src/core/install/reconcile.js +1 -1
  50. package/dist/src/core/install/uninstall.js +39 -3
  51. package/dist/src/core/reporting/cli-guide.js +9 -5
  52. package/dist/src/core/reporting/completion.js +43 -38
  53. package/dist/src/core/reporting/doctor.js +1 -1
  54. package/dist/src/core/runtime/bounded-json.js +65 -0
  55. package/dist/src/core/runtime/github.js +30 -16
  56. package/dist/src/core/runtime/mcp-recipes.js +1 -1
  57. package/dist/src/core/workspace/active-policy.js +1 -1
  58. package/docs/DEMO_SCRIPT.md +19 -23
  59. package/docs/DISCOVERED.md +251 -251
  60. package/docs/FEATURE_TEST_MATRIX.md +3 -3
  61. package/docs/LIVE_COLLABORATION.md +234 -0
  62. package/docs/REFERENCE.md +163 -0
  63. package/docs/RELEASE_REVIEW.md +1 -1
  64. package/docs/TESTING.md +17 -1
  65. package/docs/USER_TEST_GUIDE.md +138 -3
  66. package/docs/assets/loadout-discover-activate.webp +0 -0
  67. package/docs/assets/loadout-handoff-coordinate.webp +0 -0
  68. package/docs/assets/loadout-social-preview.png +0 -0
  69. package/docs/decisions/001-coordination-jsonl-locking.md +35 -0
  70. package/docs/decisions/002-local-daemon-authentication.md +32 -0
  71. package/docs/decisions/003-bounded-agent-discussions.md +91 -0
  72. package/docs/specs/BOUNDED_AGENT_DISCUSSIONS.md +169 -0
  73. package/docs/superpowers/plans/2026-09-03-coordination-hardening.md +231 -0
  74. package/docs/superpowers/plans/2026-09-03-release-readiness.md +232 -0
  75. package/docs/superpowers/plans/2026-09-04-bounded-agent-discussions.md +121 -0
  76. package/package.json +13 -4
  77. package/skills/loadout-handoff/SKILL.md +238 -0
  78. package/dist/src/core/routing/policy.js +0 -147
  79. package/dist/src/core/routing/route.js +0 -411
  80. package/docs/assets/loadout-workflow.png +0 -0
  81. package/skills/loadout-router/SKILL.md +0 -85
  82. /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-router",
8
- displayName: "Loadout Router",
9
- summary: "Lets an agent pick the right model tier for a task and hand work to another 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
- for (const target of plan.targets) {
78
- // Replace wholesale so a reinstall cannot leave files from an older
79
- // version of the skill behind.
80
- if (target.replacing)
81
- await rm(target.destination, { recursive: true });
82
- await cp(plan.source, target.destination, { recursive: true });
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
- for (const target of plan.targets)
87
- if (target.replacing)
88
- await rm(target.destination, { recursive: true });
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:", ""];
@@ -2,6 +2,30 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { writeFileAtomically } from "../install/atomic-file.js";
5
+ import { z } from "zod";
6
+ const handoffMessageSchema = z.object({
7
+ id: z.string().trim().min(1),
8
+ type: z.enum([
9
+ "task",
10
+ "handoff",
11
+ "question",
12
+ "done",
13
+ "status",
14
+ "error",
15
+ "cancel",
16
+ ]),
17
+ from: z.string().trim().min(1),
18
+ to: z.string().trim().min(1),
19
+ description: z.string().trim().min(1),
20
+ context: z.string().optional(),
21
+ timestamp: z.iso.datetime({ offset: true }),
22
+ resolves: z.string().trim().min(1).optional(),
23
+ });
24
+ function handoffValidationReason(error) {
25
+ return error.issues
26
+ .map((issue) => `${issue.path.join(".") || "message"}: ${issue.message}`)
27
+ .join("; ");
28
+ }
5
29
  /** Message types that settle a task, so it stops appearing in an inbox. */
6
30
  const TERMINAL_TYPES = new Set(["done", "error", "cancel"]);
7
31
  const HANDOFF_DIR = ".handoff";
@@ -66,7 +90,7 @@ export async function sendHandoff(projectRoot, to, description, options = {}) {
66
90
  if (!(await isHandoffInitialized(projectRoot))) {
67
91
  throw new Error("Handoff is not set up here. Send a task and it will create itself: loadout handoff <agent> '<task>'");
68
92
  }
69
- const message = {
93
+ const candidate = {
70
94
  id: randomUUID().slice(0, 8),
71
95
  type: options.type ?? "task",
72
96
  from: options.from ?? "user",
@@ -76,18 +100,24 @@ export async function sendHandoff(projectRoot, to, description, options = {}) {
76
100
  ...(options.resolves ? { resolves: options.resolves } : {}),
77
101
  timestamp: new Date().toISOString(),
78
102
  };
103
+ const parsed = handoffMessageSchema.safeParse(candidate);
104
+ if (!parsed.success)
105
+ throw new Error(`Invalid handoff message: ${handoffValidationReason(parsed.error)}`);
106
+ const message = parsed.data;
79
107
  const path = messagesPath(projectRoot);
80
108
  const line = JSON.stringify(message) + "\n";
81
109
  await writeFile(path, line, { flag: "a" });
82
110
  return message;
83
111
  }
84
112
  export async function markDone(projectRoot, messageId) {
85
- const messages = await readMessages(projectRoot);
86
- const original = messages.find((m) => m.id === messageId);
113
+ const state = await getHandoffState(projectRoot);
114
+ const original = state.messages.find((m) => m.id === messageId);
87
115
  if (!original)
88
116
  throw new Error(`Message '${messageId}' not found`);
89
- if (original.type === "done")
90
- throw new Error(`Message '${messageId}' is already done`);
117
+ if (original.type !== "task")
118
+ throw new Error(`Message '${messageId}' is not a task`);
119
+ if (state.done.some((message) => message.id === messageId))
120
+ throw new Error(`Message '${messageId}' is already settled`);
91
121
  return sendHandoff(projectRoot, original.from, `Completed: ${original.description}`, {
92
122
  from: original.to,
93
123
  type: "done",
@@ -115,10 +145,11 @@ export async function readMessagesDetailed(projectRoot) {
115
145
  if (!raw.trim())
116
146
  return;
117
147
  try {
118
- const parsed = JSON.parse(raw);
119
- if (!parsed || typeof parsed.id !== "string" || !parsed.type)
120
- throw new Error("missing id or type");
121
- messages.push(parsed);
148
+ const parsed = handoffMessageSchema.safeParse(JSON.parse(raw));
149
+ if (!parsed.success) {
150
+ throw new Error(handoffValidationReason(parsed.error));
151
+ }
152
+ messages.push(parsed.data);
122
153
  }
123
154
  catch (error) {
124
155
  corrupt.push({