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,194 @@
|
|
|
1
|
+
import { formatDiscussion, getDiscussion, listDiscussions, requiredDiscussionTurns, runDiscussion, validateDiscussionOptions, } from "../core/coordination/discussion.js";
|
|
2
|
+
import { SessionManager } from "../core/coordination/session-manager.js";
|
|
3
|
+
import { createProviderAdapters } from "../core/coordination/runtime.js";
|
|
4
|
+
import { acquireBridgeLease } from "../core/coordination/bridge-lease.js";
|
|
5
|
+
import { parseProviderSessionRef, } from "./coordination-sessions.js";
|
|
6
|
+
function assertBothProviders(values) {
|
|
7
|
+
if (values.length !== 2 ||
|
|
8
|
+
new Set(values).size !== 2 ||
|
|
9
|
+
!values.includes("claude-code") ||
|
|
10
|
+
!values.includes("codex")) {
|
|
11
|
+
throw new Error("A discussion requires exactly claude-code and codex, each once");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function parseDiscussionAgents(value) {
|
|
15
|
+
const providers = value
|
|
16
|
+
.split(",")
|
|
17
|
+
.map((provider) => provider.trim())
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
assertBothProviders(providers);
|
|
20
|
+
return providers;
|
|
21
|
+
}
|
|
22
|
+
export function parseDiscussionSessions(values) {
|
|
23
|
+
if (values.length !== 2) {
|
|
24
|
+
throw new Error("A discussion requires exactly two provider sessions");
|
|
25
|
+
}
|
|
26
|
+
const sessions = values.map(parseProviderSessionRef);
|
|
27
|
+
assertBothProviders(sessions.map((session) => session.provider));
|
|
28
|
+
if (sessions[0].sessionId === sessions[1].sessionId) {
|
|
29
|
+
throw new Error("Discussion session IDs must be different");
|
|
30
|
+
}
|
|
31
|
+
return sessions;
|
|
32
|
+
}
|
|
33
|
+
export function resolveDiscussionSelection(options) {
|
|
34
|
+
if (options.agents && options.sessions) {
|
|
35
|
+
throw new Error("Choose either --agents or --sessions, not both");
|
|
36
|
+
}
|
|
37
|
+
if (!options.agents && !options.sessions) {
|
|
38
|
+
throw new Error("Discussion start requires --agents or --sessions");
|
|
39
|
+
}
|
|
40
|
+
const mode = options.agents ? "fresh" : "existing";
|
|
41
|
+
const selected = options.agents
|
|
42
|
+
? parseDiscussionAgents(options.agents).map((provider) => ({
|
|
43
|
+
provider,
|
|
44
|
+
}))
|
|
45
|
+
: parseDiscussionSessions(options.sessions ?? []).map((session) => ({
|
|
46
|
+
provider: session.provider,
|
|
47
|
+
sessionId: session.sessionId,
|
|
48
|
+
}));
|
|
49
|
+
if (options.proposer) {
|
|
50
|
+
if (!selected.some((item) => item.provider === options.proposer)) {
|
|
51
|
+
throw new Error("--proposer must be claude-code or codex and be part of the discussion");
|
|
52
|
+
}
|
|
53
|
+
selected.sort((left) => (left.provider === options.proposer ? -1 : 1));
|
|
54
|
+
}
|
|
55
|
+
return { mode, participants: selected };
|
|
56
|
+
}
|
|
57
|
+
export function discussionTimeoutMs(seconds) {
|
|
58
|
+
if (!Number.isSafeInteger(seconds) || seconds < 10 || seconds > 600) {
|
|
59
|
+
throw new Error("Discussion timeout must be an integer between 10 and 600 seconds");
|
|
60
|
+
}
|
|
61
|
+
return seconds * 1_000;
|
|
62
|
+
}
|
|
63
|
+
/** Adapt a fresh or already-attached provider session to the discussion port. */
|
|
64
|
+
export function createSessionParticipant(sessions, selection, role, cwd, timeoutMs) {
|
|
65
|
+
let sessionId = selection.sessionId;
|
|
66
|
+
return {
|
|
67
|
+
agent: selection.provider,
|
|
68
|
+
role,
|
|
69
|
+
async respond(prompt) {
|
|
70
|
+
if (!sessionId) {
|
|
71
|
+
const started = await sessions.startSession(selection.provider, cwd, prompt, timeoutMs);
|
|
72
|
+
sessionId = started.sessionId;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
const accepted = await sessions.submitTurn(sessionId, prompt, timeoutMs);
|
|
76
|
+
if (!accepted) {
|
|
77
|
+
throw new Error(`${selection.provider}:${sessionId} rejected the discussion turn`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return sessions.getLastResponse(sessionId) ?? "";
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function createManager(projectRoot) {
|
|
85
|
+
return new SessionManager({
|
|
86
|
+
projectRoot,
|
|
87
|
+
adapters: createProviderAdapters(),
|
|
88
|
+
autoSubmit: false,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** Register opt-in bounded provider discussions under `loadout coord discuss`. */
|
|
92
|
+
export function registerCoordinationDiscussions(coord) {
|
|
93
|
+
const discuss = coord
|
|
94
|
+
.command("discuss")
|
|
95
|
+
.description("Bounded Claude Code ↔ Codex design discussions");
|
|
96
|
+
discuss
|
|
97
|
+
.command("list")
|
|
98
|
+
.description("List recorded design discussions without running an agent")
|
|
99
|
+
.option("--json", "machine-readable JSON output")
|
|
100
|
+
.action(async (options) => {
|
|
101
|
+
const discussions = await listDiscussions(process.cwd());
|
|
102
|
+
if (options.json) {
|
|
103
|
+
console.log(JSON.stringify(discussions, null, 2));
|
|
104
|
+
}
|
|
105
|
+
else if (discussions.length === 0) {
|
|
106
|
+
console.log("No design discussions recorded.");
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
for (const thread of discussions) {
|
|
110
|
+
console.log(`${thread.threadId} ${thread.status} ${thread.participants.join(" ↔ ")} ${thread.topic}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
discuss
|
|
115
|
+
.command("show")
|
|
116
|
+
.description("Show one public discussion transcript without running an agent")
|
|
117
|
+
.argument("<thread-id>", "discussion thread ID")
|
|
118
|
+
.option("--json", "machine-readable JSON output")
|
|
119
|
+
.action(async (threadId, options) => {
|
|
120
|
+
const discussion = await getDiscussion(process.cwd(), threadId);
|
|
121
|
+
if (!discussion) {
|
|
122
|
+
throw new Error(`No discussion named '${threadId}'`);
|
|
123
|
+
}
|
|
124
|
+
console.log(options.json
|
|
125
|
+
? JSON.stringify(discussion, null, 2)
|
|
126
|
+
: formatDiscussion(discussion));
|
|
127
|
+
});
|
|
128
|
+
discuss
|
|
129
|
+
.command("start")
|
|
130
|
+
.description("Run a bounded design discussion (spends paid provider turns)")
|
|
131
|
+
.argument("<topic>", "technical question for Claude Code and Codex")
|
|
132
|
+
.option("--agents <providers>", "start fresh sessions: claude-code,codex")
|
|
133
|
+
.option("--sessions <references...>", "reuse provider sessions: claude-code:<id> codex:<id>")
|
|
134
|
+
.option("--proposer <provider>", "claude-code or codex")
|
|
135
|
+
.option("--rounds <n>", "rounds; each agent speaks once per round", Number, 2)
|
|
136
|
+
.option("--max-turns <n>", "hard paid-turn budget", Number, 17)
|
|
137
|
+
.option("--timeout <seconds>", "timeout for each paid provider turn", Number, 120)
|
|
138
|
+
.option("--thread-id <id>", "stable discussion ID")
|
|
139
|
+
.option("--initiator <name>", "coordination identity starting the discussion", "user")
|
|
140
|
+
.option("--json", "machine-readable final output")
|
|
141
|
+
.action(async (topic, options) => {
|
|
142
|
+
const projectRoot = process.cwd();
|
|
143
|
+
const selection = resolveDiscussionSelection(options);
|
|
144
|
+
const timeoutMs = discussionTimeoutMs(options.timeout);
|
|
145
|
+
const noop = async () => "validated only";
|
|
146
|
+
validateDiscussionOptions({
|
|
147
|
+
topic,
|
|
148
|
+
rounds: options.rounds,
|
|
149
|
+
maxTurns: options.maxTurns,
|
|
150
|
+
participants: [
|
|
151
|
+
{
|
|
152
|
+
agent: selection.participants[0].provider,
|
|
153
|
+
role: "proposer",
|
|
154
|
+
respond: noop,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
agent: selection.participants[1].provider,
|
|
158
|
+
role: "reviewer",
|
|
159
|
+
respond: noop,
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
});
|
|
163
|
+
const plannedTurns = requiredDiscussionTurns(options.rounds);
|
|
164
|
+
console.error(`Starting bounded discussion: exactly ${plannedTurns} paid provider turns (${options.rounds} round(s) plus synthesis).`);
|
|
165
|
+
const lease = await acquireBridgeLease(projectRoot);
|
|
166
|
+
const sessions = createManager(projectRoot);
|
|
167
|
+
try {
|
|
168
|
+
await sessions.start();
|
|
169
|
+
if (selection.mode === "existing") {
|
|
170
|
+
for (const item of selection.participants) {
|
|
171
|
+
await sessions.attachSession(item.provider, item.sessionId, projectRoot);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const result = await runDiscussion(projectRoot, {
|
|
175
|
+
topic,
|
|
176
|
+
rounds: options.rounds,
|
|
177
|
+
maxTurns: options.maxTurns,
|
|
178
|
+
...(options.threadId ? { threadId: options.threadId } : {}),
|
|
179
|
+
initiatedBy: options.initiator,
|
|
180
|
+
participants: [
|
|
181
|
+
createSessionParticipant(sessions, selection.participants[0], "proposer", projectRoot, timeoutMs),
|
|
182
|
+
createSessionParticipant(sessions, selection.participants[1], "reviewer", projectRoot, timeoutMs),
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
console.log(options.json
|
|
186
|
+
? JSON.stringify(result, null, 2)
|
|
187
|
+
: formatDiscussion(result.state));
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
await sessions.stop();
|
|
191
|
+
await lease.release();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { SessionManager } from "../core/coordination/session-manager.js";
|
|
2
|
+
import { createProviderAdapters } from "../core/coordination/runtime.js";
|
|
3
|
+
import { acquireBridgeLease } from "../core/coordination/bridge-lease.js";
|
|
4
|
+
const SUPPORTED_PROVIDERS = new Set(["claude-code", "codex"]);
|
|
5
|
+
export function parseProviderSessionRef(value) {
|
|
6
|
+
const separator = value.indexOf(":");
|
|
7
|
+
if (separator < 1) {
|
|
8
|
+
throw new Error(`Invalid session reference '${value}'; expected provider:session-id`);
|
|
9
|
+
}
|
|
10
|
+
const provider = value.slice(0, separator);
|
|
11
|
+
const sessionId = value.slice(separator + 1).trim();
|
|
12
|
+
if (!SUPPORTED_PROVIDERS.has(provider)) {
|
|
13
|
+
throw new Error(`Unsupported provider '${provider}'; supported providers: claude-code, codex`);
|
|
14
|
+
}
|
|
15
|
+
if (!sessionId) {
|
|
16
|
+
throw new Error(`Invalid session reference '${value}'; session id is empty`);
|
|
17
|
+
}
|
|
18
|
+
return { provider, sessionId };
|
|
19
|
+
}
|
|
20
|
+
function manager(projectRoot, autoSubmit, maxAutoTurnsPerSession = 20) {
|
|
21
|
+
return new SessionManager({
|
|
22
|
+
projectRoot,
|
|
23
|
+
adapters: createProviderAdapters(),
|
|
24
|
+
autoSubmit,
|
|
25
|
+
maxAutoTurnsPerSession,
|
|
26
|
+
onPendingEvents(session, events) {
|
|
27
|
+
console.error(`Could not route ${events.length} event(s) to ${session.provider}:${session.sessionId}; the session may be busy or unavailable.`);
|
|
28
|
+
},
|
|
29
|
+
...(autoSubmit
|
|
30
|
+
? {
|
|
31
|
+
onTurnCompleted(session, response) {
|
|
32
|
+
console.log(`\n[${session.provider}:${session.sessionId}] ${response ?? "Coordination turn completed."}`);
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
: {}),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
async function withStoppedManager(projectRoot, operation) {
|
|
39
|
+
const sessions = manager(projectRoot, false);
|
|
40
|
+
await sessions.start();
|
|
41
|
+
try {
|
|
42
|
+
return await operation(sessions);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await sessions.stop();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Register opt-in provider-driven turns under `loadout coord agents`. */
|
|
49
|
+
export function registerCoordinationSessions(coord) {
|
|
50
|
+
const agents = coord
|
|
51
|
+
.command("agents")
|
|
52
|
+
.description("Bridge provider sessions so coordination events become follow-up turns (beta)");
|
|
53
|
+
agents
|
|
54
|
+
.command("detect")
|
|
55
|
+
.description("Detect supported provider runtimes")
|
|
56
|
+
.option("--json", "machine-readable JSON output")
|
|
57
|
+
.action(async (options) => {
|
|
58
|
+
const providers = await withStoppedManager(process.cwd(), async (sessions) => sessions.detectProviders());
|
|
59
|
+
if (options.json) {
|
|
60
|
+
console.log(JSON.stringify(providers, null, 2));
|
|
61
|
+
}
|
|
62
|
+
else if (providers.length === 0) {
|
|
63
|
+
console.log("No supported provider runtimes detected.");
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
for (const provider of providers) {
|
|
67
|
+
console.log(`${provider.provider}: ${provider.version}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
agents
|
|
72
|
+
.command("list")
|
|
73
|
+
.description("List sessions tracked by this project")
|
|
74
|
+
.option("--json", "machine-readable JSON output")
|
|
75
|
+
.action(async (options) => {
|
|
76
|
+
const tracked = await withStoppedManager(process.cwd(), async (sessions) => sessions.getSessions());
|
|
77
|
+
if (options.json) {
|
|
78
|
+
console.log(JSON.stringify(tracked, null, 2));
|
|
79
|
+
}
|
|
80
|
+
else if (tracked.length === 0) {
|
|
81
|
+
console.log("No provider sessions tracked for this project.");
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
for (const session of tracked) {
|
|
85
|
+
console.log(`${session.provider}:${session.sessionId} cursor=${session.cursor} ${session.active ? "active" : "detached"}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
agents
|
|
90
|
+
.command("start")
|
|
91
|
+
.description("Start and track a provider session (runs a paid agent turn)")
|
|
92
|
+
.argument("<provider>", "claude-code or codex")
|
|
93
|
+
.argument("[prompt...]", "initial task prompt")
|
|
94
|
+
.option("--json", "machine-readable JSON output")
|
|
95
|
+
.action(async (provider, prompt, options) => {
|
|
96
|
+
if (!SUPPORTED_PROVIDERS.has(provider)) {
|
|
97
|
+
throw new Error(`Unsupported provider '${provider}'; use claude-code or codex`);
|
|
98
|
+
}
|
|
99
|
+
const result = await withStoppedManager(process.cwd(), async (sessions) => {
|
|
100
|
+
const session = await sessions.startSession(provider, process.cwd(), prompt.join(" "));
|
|
101
|
+
return {
|
|
102
|
+
session,
|
|
103
|
+
response: sessions.getLastResponse(session.sessionId),
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
const { session, response } = result;
|
|
107
|
+
if (options.json) {
|
|
108
|
+
console.log(JSON.stringify({ session, response }, null, 2));
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
console.log(`Started ${provider}:${session.sessionId}`);
|
|
112
|
+
if (response)
|
|
113
|
+
console.log(`\n${response}`);
|
|
114
|
+
console.log(`Keep it live with: loadout coord agents bridge ${provider}:${session.sessionId}`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
agents
|
|
118
|
+
.command("attach")
|
|
119
|
+
.description("Track an existing provider session without running a turn")
|
|
120
|
+
.argument("<session>", "provider:session-id")
|
|
121
|
+
.option("--json", "machine-readable JSON output")
|
|
122
|
+
.action(async (reference, options) => {
|
|
123
|
+
const parsed = parseProviderSessionRef(reference);
|
|
124
|
+
const session = await withStoppedManager(process.cwd(), async (sessions) => sessions.attachSession(parsed.provider, parsed.sessionId, process.cwd()));
|
|
125
|
+
if (options.json) {
|
|
126
|
+
console.log(JSON.stringify(session, null, 2));
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
console.log(`Attached ${parsed.provider}:${parsed.sessionId}`);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
agents
|
|
133
|
+
.command("send")
|
|
134
|
+
.description("Send one follow-up turn to a tracked provider session")
|
|
135
|
+
.argument("<session-id>", "tracked provider session id")
|
|
136
|
+
.argument("<message...>", "follow-up message")
|
|
137
|
+
.option("--json", "machine-readable JSON output")
|
|
138
|
+
.action(async (sessionId, message, options) => {
|
|
139
|
+
const result = await withStoppedManager(process.cwd(), async (sessions) => {
|
|
140
|
+
const tracked = sessions
|
|
141
|
+
.getSessions()
|
|
142
|
+
.find((session) => session.sessionId === sessionId);
|
|
143
|
+
if (!tracked) {
|
|
144
|
+
throw new Error(`Session ${sessionId} is not tracked; use 'loadout coord agents attach provider:${sessionId}' first`);
|
|
145
|
+
}
|
|
146
|
+
await sessions.resumeSession(tracked.provider, tracked.sessionId, process.cwd());
|
|
147
|
+
const sent = await sessions.submitTurn(sessionId, message.join(" "));
|
|
148
|
+
return {
|
|
149
|
+
sent,
|
|
150
|
+
response: sessions.getLastResponse(sessionId),
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
if (!result.sent) {
|
|
154
|
+
throw new Error(`Provider session ${sessionId} did not accept the turn`);
|
|
155
|
+
}
|
|
156
|
+
if (options.json) {
|
|
157
|
+
console.log(JSON.stringify({
|
|
158
|
+
sent: true,
|
|
159
|
+
sessionId,
|
|
160
|
+
response: result.response,
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
console.log(`Sent follow-up turn to ${sessionId}`);
|
|
165
|
+
if (result.response)
|
|
166
|
+
console.log(`\n${result.response}`);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
agents
|
|
170
|
+
.command("bridge")
|
|
171
|
+
.description("Keep sessions connected and route coordination events as follow-up turns")
|
|
172
|
+
.argument("<sessions...>", "one or more provider:session-id references")
|
|
173
|
+
.option("--max-turns <n>", "maximum automatic turns per session before pausing", Number, 20)
|
|
174
|
+
.action(async (references, options) => {
|
|
175
|
+
const projectRoot = process.cwd();
|
|
176
|
+
const lease = await acquireBridgeLease(projectRoot);
|
|
177
|
+
const sessions = manager(projectRoot, true, options.maxTurns);
|
|
178
|
+
try {
|
|
179
|
+
await sessions.start();
|
|
180
|
+
for (const reference of references) {
|
|
181
|
+
const parsed = parseProviderSessionRef(reference);
|
|
182
|
+
await sessions.attachSession(parsed.provider, parsed.sessionId, projectRoot);
|
|
183
|
+
}
|
|
184
|
+
console.log(`Bridge active for ${references.join(", ")}. Coordination events will be delivered at safe turn boundaries.`);
|
|
185
|
+
console.log("Press Ctrl+C to stop.");
|
|
186
|
+
await new Promise((resolve) => {
|
|
187
|
+
const stop = () => resolve();
|
|
188
|
+
process.once("SIGINT", stop);
|
|
189
|
+
process.once("SIGTERM", stop);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
await sessions.stop();
|
|
194
|
+
await lease.release();
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
@@ -47,7 +47,7 @@ export function registerInventory(program) {
|
|
|
47
47
|
force: options.force,
|
|
48
48
|
onProgress: (message) => console.log(`→ ${message}`),
|
|
49
49
|
});
|
|
50
|
-
console.log(`Removed ${result.removedPackages} managed package(s), ${result.removedRuntimeTools} runtime tool(s), daily jobs, cache, library, and Loadout state.`);
|
|
50
|
+
console.log(`Removed ${result.removedPackages} managed package(s), ${result.removedRuntimeTools} runtime tool(s), ${result.removedFirstPartySkills} first-party skill install(s), daily jobs, cache, library, and Loadout state.`);
|
|
51
51
|
if (options.removeCli) {
|
|
52
52
|
await uninstallGlobalCli();
|
|
53
53
|
console.log("Removed the global loadout-ai CLI. Open a new shell before checking the command again.");
|
|
@@ -215,7 +215,9 @@ export function registerInventory(program) {
|
|
|
215
215
|
});
|
|
216
216
|
console.log(options.json
|
|
217
217
|
? JSON.stringify({ plan, result }, null, 2)
|
|
218
|
-
:
|
|
218
|
+
: result.snapshotId
|
|
219
|
+
? `${formatReconcilePlan(plan)}\n\nReconciled ${result.adopted} exact group(s) and ${result.updated} outdated group(s). Snapshot: ${result.snapshotId}`
|
|
220
|
+
: `${formatReconcilePlan(plan)}\n\nNothing to reconcile. No snapshot was needed.`);
|
|
219
221
|
});
|
|
220
222
|
program
|
|
221
223
|
.command("adopt")
|
|
@@ -136,6 +136,30 @@ export async function inspectAgent(agent) {
|
|
|
136
136
|
export async function inspectAgents(agents) {
|
|
137
137
|
return Promise.all(agents.map(inspectAgent));
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Count installable units rather than raw filesystem entries. A skill is one
|
|
141
|
+
* directory containing SKILL.md, so counting every nested file reports a number
|
|
142
|
+
* many times larger than the number of skills the user actually has.
|
|
143
|
+
*/
|
|
144
|
+
export function countComponentUnits(component) {
|
|
145
|
+
if (!component.scanned || !component.directoryExists)
|
|
146
|
+
return 0;
|
|
147
|
+
const skillRoots = new Set();
|
|
148
|
+
let looseFiles = 0;
|
|
149
|
+
for (const entry of component.entries) {
|
|
150
|
+
if (entry.kind !== "file")
|
|
151
|
+
continue;
|
|
152
|
+
const segments = entry.path.split("/");
|
|
153
|
+
const name = segments[segments.length - 1];
|
|
154
|
+
if (name === "SKILL.md") {
|
|
155
|
+
// Depth 1 means SKILL.md sits at the component root, which is one unit.
|
|
156
|
+
skillRoots.add(segments.slice(0, -1).join("/") || ".");
|
|
157
|
+
}
|
|
158
|
+
else if (segments.length === 1)
|
|
159
|
+
looseFiles += 1;
|
|
160
|
+
}
|
|
161
|
+
return skillRoots.size || looseFiles;
|
|
162
|
+
}
|
|
139
163
|
/**
|
|
140
164
|
* One agent's inventory. The default is a two-line summary: what is installed
|
|
141
165
|
* and which component kinds the adapter supports. Adapter internals — the
|
|
@@ -155,16 +179,14 @@ export function formatAgentInventory(inventory, options = {}) {
|
|
|
155
179
|
}
|
|
156
180
|
}
|
|
157
181
|
else {
|
|
158
|
-
const items = inventory.components
|
|
159
|
-
.filter((component) => component.scanned && component.directoryExists)
|
|
160
|
-
.reduce((total, component) => total + component.entries.length, 0);
|
|
182
|
+
const items = inventory.components.reduce((total, component) => total + countComponentUnits(component), 0);
|
|
161
183
|
const supported = inventory.components
|
|
162
184
|
.filter((component) => component.compatibility !== "unsupported")
|
|
163
185
|
.map((component) => component.type);
|
|
164
186
|
const skills = inventory.components.find((component) => component.type === "skill");
|
|
165
187
|
if (skills?.directory)
|
|
166
188
|
lines.push(` ${skills.directory}`);
|
|
167
|
-
lines.push(` ${items}
|
|
189
|
+
lines.push(` ${items} skills | supports: ${supported.join(", ")}`);
|
|
168
190
|
}
|
|
169
191
|
for (const warning of inventory.warnings)
|
|
170
192
|
lines.push(` ! ${warning}`);
|
|
@@ -5,6 +5,7 @@ import { formatSchemaError, providerModelConfigurationSchema, } from "../../shar
|
|
|
5
5
|
import { writeFileAtomically } from "../install/atomic-file.js";
|
|
6
6
|
import { ensureDirectory, loadoutHome } from "../agents/paths.js";
|
|
7
7
|
import { runMutationTransaction } from "../install/transaction.js";
|
|
8
|
+
import { boundedJson } from "../runtime/bounded-json.js";
|
|
8
9
|
export const defaultModelConfigurationPath = () => join(loadoutHome(), "models.json");
|
|
9
10
|
/**
|
|
10
11
|
* Validate a portable, provider-neutral model configuration. This is not a
|
|
@@ -87,7 +88,7 @@ export async function requestOpenRouter(configuration, selectionId, messages, op
|
|
|
87
88
|
const token = await options.resolveCredential(selection.credential);
|
|
88
89
|
if (!token)
|
|
89
90
|
throw new Error("OpenRouter credential reference did not resolve");
|
|
90
|
-
const response = await (options.fetcher ?? fetch
|
|
91
|
+
const { response, value } = await boundedJson(options.fetcher ?? fetch, `${selection.endpoint.replace(/\/$/, "")}/chat/completions`, {
|
|
91
92
|
method: "POST",
|
|
92
93
|
headers: {
|
|
93
94
|
accept: "application/json",
|
|
@@ -95,8 +96,12 @@ export async function requestOpenRouter(configuration, selectionId, messages, op
|
|
|
95
96
|
authorization: `Bearer ${token}`,
|
|
96
97
|
},
|
|
97
98
|
body: JSON.stringify({ model: selection.model, messages }),
|
|
99
|
+
}, {
|
|
100
|
+
timeoutMs: 30_000,
|
|
101
|
+
maxBytes: 8 * 1024 * 1024,
|
|
102
|
+
label: "OpenRouter",
|
|
98
103
|
});
|
|
99
104
|
if (!response.ok)
|
|
100
105
|
throw new Error(`OpenRouter request failed (${response.status})`);
|
|
101
|
-
return
|
|
106
|
+
return value;
|
|
102
107
|
}
|
|
@@ -14,6 +14,7 @@ function bundledCatalogPath() {
|
|
|
14
14
|
const candidates = [
|
|
15
15
|
join(moduleDirectory, "..", "..", "catalog", "packages.json"),
|
|
16
16
|
join(moduleDirectory, "..", "..", "..", "catalog", "packages.json"),
|
|
17
|
+
join(moduleDirectory, "..", "..", "..", "..", "catalog", "packages.json"),
|
|
17
18
|
join(process.cwd(), "catalog", "packages.json"),
|
|
18
19
|
];
|
|
19
20
|
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { cp, lstat, mkdir, readFile, readdir, rm, writeFile, } from "node:fs/promises";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { cp, lstat, mkdir, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises";
|
|
3
3
|
import { join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { loadoutHome } from "../agents/paths.js";
|
|
5
|
+
import { boundedJson } from "../runtime/bounded-json.js";
|
|
5
6
|
import { analyzeUpdateSafety } from "./safety.js";
|
|
6
7
|
const NAME = /^[a-z0-9][a-z0-9._-]*$/;
|
|
8
|
+
/** A SHA-256 digest, and nothing that could traverse a path. */
|
|
9
|
+
const DIGEST = /^[a-f0-9]{64}$/;
|
|
7
10
|
const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;
|
|
8
11
|
export function parsePackageDescriptor(value) {
|
|
9
12
|
if (!value || typeof value !== "object")
|
|
@@ -266,18 +269,39 @@ function registryEndpoint(base, path) {
|
|
|
266
269
|
endpoint.hash = "";
|
|
267
270
|
return endpoint;
|
|
268
271
|
}
|
|
272
|
+
/** Registry responses are untrusted input: bound the wait and the body. */
|
|
273
|
+
const REGISTRY_TIMEOUT_MS = 30_000;
|
|
274
|
+
const REGISTRY_MAX_BYTES = 32 * 1024 * 1024;
|
|
275
|
+
async function registryJson(input, init = {}) {
|
|
276
|
+
const { response, value } = await boundedJson(fetch, input, init, {
|
|
277
|
+
timeoutMs: REGISTRY_TIMEOUT_MS,
|
|
278
|
+
maxBytes: REGISTRY_MAX_BYTES,
|
|
279
|
+
label: "Remote registry",
|
|
280
|
+
});
|
|
281
|
+
return { ok: response.ok, status: response.status, value };
|
|
282
|
+
}
|
|
269
283
|
export async function fetchRemoteRegistryPackage(registry, name, version) {
|
|
270
284
|
if (!NAME.test(name) || !VERSION.test(version))
|
|
271
285
|
throw new Error("Invalid remote registry package name or version");
|
|
272
286
|
const base = registryUrl(registry);
|
|
273
|
-
const response = await
|
|
287
|
+
const response = await registryJson(registryEndpoint(base, `/v1/packages/${encodeURIComponent(name)}/${encodeURIComponent(version)}`));
|
|
274
288
|
if (!response.ok)
|
|
275
289
|
throw new Error(`Remote registry returned ${response.status}`);
|
|
276
|
-
const bundle =
|
|
290
|
+
const bundle = response.value;
|
|
291
|
+
// `digest` arrives from a remote server and is used to build a path that is
|
|
292
|
+
// later deleted recursively. Validate its shape before it can become one:
|
|
293
|
+
// a value such as "../../.." would otherwise escape the cache root.
|
|
294
|
+
if (typeof bundle?.digest !== "string" || !DIGEST.test(bundle.digest))
|
|
295
|
+
throw new Error("Remote registry returned a bundle whose digest is not a SHA-256 hex string");
|
|
277
296
|
const key = createHash("sha256")
|
|
278
297
|
.update(base.origin + base.pathname)
|
|
279
298
|
.digest("hex");
|
|
280
|
-
const
|
|
299
|
+
const root = join(loadoutHome(), "cache", "registry", key, name, version);
|
|
300
|
+
const target = resolve(root, bundle.digest);
|
|
301
|
+
// Belt and braces: even with a validated digest, never operate on a path
|
|
302
|
+
// that resolved outside the directory it was meant to live in.
|
|
303
|
+
if (target !== root && !target.startsWith(root + sep))
|
|
304
|
+
throw new Error("Remote registry bundle resolved outside the cache root");
|
|
281
305
|
try {
|
|
282
306
|
const packed = await packPackage(target);
|
|
283
307
|
if (packed.digest === bundle.digest)
|
|
@@ -286,9 +310,19 @@ export async function fetchRemoteRegistryPackage(registry, name, version) {
|
|
|
286
310
|
catch {
|
|
287
311
|
/* import below */
|
|
288
312
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
await
|
|
313
|
+
// Import into a scratch directory and swap it in, so a failed or malicious
|
|
314
|
+
// import cannot leave the cache holding a half-written package.
|
|
315
|
+
await mkdir(root, { recursive: true });
|
|
316
|
+
const staging = `${target}.incoming-${randomUUID().slice(0, 8)}`;
|
|
317
|
+
try {
|
|
318
|
+
await importRegistryBundle(bundle, staging);
|
|
319
|
+
await rm(target, { recursive: true, force: true });
|
|
320
|
+
await rename(staging, target);
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
await rm(staging, { recursive: true, force: true });
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
292
326
|
return { path: target, digest: bundle.digest };
|
|
293
327
|
}
|
|
294
328
|
export async function publishRemotePackage(root, registry, token, options = {}) {
|
|
@@ -296,7 +330,7 @@ export async function publishRemotePackage(root, registry, token, options = {})
|
|
|
296
330
|
throw new Error("Remote registry token is required");
|
|
297
331
|
const bundle = await createRegistryBundle(root);
|
|
298
332
|
const base = registryUrl(registry);
|
|
299
|
-
const response = await
|
|
333
|
+
const response = await registryJson(registryEndpoint(base, "/v1/packages"), {
|
|
300
334
|
method: "POST",
|
|
301
335
|
headers: {
|
|
302
336
|
authorization: `Bearer ${token}`,
|
|
@@ -305,7 +339,7 @@ export async function publishRemotePackage(root, registry, token, options = {})
|
|
|
305
339
|
},
|
|
306
340
|
body: JSON.stringify(bundle),
|
|
307
341
|
});
|
|
308
|
-
const result =
|
|
342
|
+
const result = response.value;
|
|
309
343
|
if (!response.ok)
|
|
310
344
|
throw new Error(result.error ?? `Remote registry returned ${response.status}`);
|
|
311
345
|
return {
|